Как автоматически выводить типы в функциональном конвейере TypeScript с использованием функций преобразователя высшего порядка?

Я создаю симулятор, в котором объект шаг за шагом преобразуется с помощью различных функций, используя канал из fp-ts. Чтобы сделать код более выразительным, я использую функции высшего порядка для создания этих преобразователей.

Вот пример того, как я хочу, чтобы мой код выглядел:

pipe(
    { name: "Nick" }, 
    merge({ age: 34 }), 
    merge({ job: "programmer" }), 
    merge({ married: true })
);

В идеале я хочу обеспечить типобезопасность, указав типы для канала, как показано ниже:

type Guy = { name: string };
type GuyWithAge = Guy & { age: number };
type GuyWithAgeAndJob = GuyWithAge & { job: string };
type GuyWithAgeAndJobAndMarried = GuyWithAgeAndJob & { married: boolean };

pipe<Guy, GuyWithAge, GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>(
    { name: "Nick" },
    merge({ age: 34 }), 
    merge({ job: "programmer" }), 
    merge({ married: true })
);

// results in { name: "Nick", age: 34, job: "programmer", married: true }

Я хочу, чтобы TypeScript выводил типы на каждом этапе, чтобы он принимал только разницу между типами, гарантируя, что преобразование объекта является типобезопасным.

Мне удалось реализовать это с помощью дженериков и утилит TypeScript, но есть одна оговорка:

// Merge is like A & B but it overwrites properties on A with B. Like {...A, ...B}
type Merge<First extends object, Second extends object> = Omit<
  First,
  keyof Second
> &
  Second;

// A delta is the object that you merge with From to create To
export type Delta<From extends object, To extends object> = {
  [K in keyof To as K extends keyof From
    ? To[K] extends From[K]
      ? never
      : K
    : K]: To[K];
};

const merge =
  <First extends object, Second extends object>(
    delta: Delta<First, Second>,
  ) =>
  (obj: First): Merge<First, Delta<First, Second>> => {
    const merged = {
      ...obj,
      ...delta,
    };
    return merged;
  };

type Guy = { name: string };
type GuyWithAge = Guy & { age: number };
type GuyWithAgeAndJob = GuyWithAge & { job: string };
type GuyWithAgeAndJobAndMarried = GuyWithAgeAndJob & { married: boolean };

const guy = pipe<Guy, GuyWithAge, GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>(
  { name: "Nick" },
  merge<Guy, GuyWithAge>({ age: 34 }),
  merge<GuyWithAge, GuyWithAgeAndJob>({ job: "programmer" }),
  merge<GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>({ married: true })
);

Проблема в том, что мне нужно явно указывать типы каждый раз, когда я вызываю слияние, что приводит к многословию.

Мой вопрос: есть ли способ заставить TypeScript автоматически выводить правильные дженерики для каждого вызова слияния внутри канала, чтобы мне не приходилось вручную указывать типы на каждом этапе?

Редактировать

Я развил проблему дальше. pipe ожидает функцию типа A => B. Когда я передаю merge<First, Second>(delta), оно возвращается (obj: First) => Merge<First, Delta<First, Second>>.

Это означает, что я предоставляю трубе (obj: First) => Merge<First, Delta<First, Second>> там, где она ожидает A => B. Таким образом, A должно сопоставляться с First, а B должно сопоставляться с Merge<First, Delta<First, Second>>, что в идеале упрощается до Second (но, возможно, это упрощение неверно?).

Я пытаюсь понять, почему TypeScript не может сделать вывод, что A соответствует First, а B соответствует Second, что избавило бы от необходимости передавать дженерики в merge. Моя цель — лучше понять механизм вывода TypeScript и/или исправить код, чтобы вывод работал так, как ожидалось.

Я могу заставить трубку получить Merge<object, { married: boolean }>, но не GuyWithAgeAndJobAndMarried с type InvokeResult<First, Fn> = Fn extends (first: First) => infer Second ? Second extends object ? Second : never : never и type PipeResult<First, Fns> = Fns extends [infer Fn, ...infer Rest] ? PipeResult<InvokeResult<First, Fn>, Rest> : First

Caleth 09.08.2024 13:02

У вашей функции merge() нет места, из которого можно было бы вывести First или Second, поскольку она не может инвертировать тип Delta, чтобы получить пару входных данных из выходных. Вместо этого я бы предложил сделать merge()generic прямо во входных данных, как показано в этой ссылке на игровую площадку. Это полностью решает вопрос? Если да, то я напишу ответ с объяснением; если нет, то что мне не хватает?

jcalz 09.08.2024 15:07

@Калет, не мог бы ты поделиться ссылкой на игровую площадку, чтобы показать, как эти типы реализованы?

Nick Manning 09.08.2024 18:30

@jcalz да, ты прав насчет Дельты. Я надеялся, что машинописный текст каким-то образом сможет определить первое/второе откуда-то еще, но я не был уверен, как это сделать. Ваше решение довольно хорошее - оно позволяет передавать дополнительные свойства в слияние, но это не имеет большого значения. Я так понимаю, идеального решения не существует?

Nick Manning 09.08.2024 18:30

У меня только что pipe кастинг на PipeResult: детская площадка

Caleth 09.08.2024 18:40

«Дополнительные» свойства означают что? Без минимального воспроизводимого примера , демонстрирующего то, что вы считаете несовершенством, я понятия не имею. –

jcalz 09.08.2024 20:22

@jcalz Я просто имею в виду, что на вашей игровой площадке я могу добавить больше свойств к каждому аргументу, передаваемому для слияния. Например, измените merge({age: 34}) на ({age: 34, extraproperty: true}), и я не получу ошибку. Вот площадка с моим кодом, чтобы можно было сравнить. В моем примере я получаю сообщение об ошибке при добавлении дополнительных свойств для слияния, как и следовало ожидать.

Nick Manning 09.08.2024 20:27

@Caleth Моя цель — исключить (вывести) типы, передаваемые в merge(), а не в Pipe(). Посмотрите мою игровую площадку в моем комментарии к jcalz. Я могу удалить типы, переданные pipe, но не каждому merge вызову.

Nick Manning 09.08.2024 20:33

Однако дополнительные свойства не являются ошибками типа. Мне трудно представить, почему вам нужна ошибка типа, особенно если вы не хотите вручную прописывать типы GuyWithAge и GuyWithAgeAndJob везде. Если вы хотите записать эти типы, то вы можете это сделать, это просто немного другой тип. Итак, это все еще не «идеально» или мне следует продолжить?

jcalz 09.08.2024 20:36

@jcalz Мне нравится, что здесь появляется ошибка «Литерал объекта не допускает дополнительных свойств». Я пытаюсь использовать типы как источник истины для структуры объекта, и я не хочу, чтобы было легко добавлять дополнительные свойства непосредственно в merge(). Я понимаю, почему мне не следует придавать этому большого значения, но можно ли удовлетворить эту просьбу? Я могу заставить его работать по ссылке на игровую площадку, но мне кажется излишним указывать типы для слияния, потому что они уже переданы в канал.

Nick Manning 09.08.2024 21:02

@jcalz Суть моего вопроса такова: если вы посмотрите на мою ссылку на игровую площадку, то кажется излишним, что мне все еще приходится передавать типы merge, хотя я уже передаю их pipe. В настоящее время я пытаюсь понять, почему я считаю, что это возможно — возможно, это ошибка в моем мышлении или в том, как работает машинописный текст.

Nick Manning 09.08.2024 21:13

Лишние свойства не являются ошибками типа; о них предупреждают только в определенных обстоятельствах, и почти наверняка больше хлопот, чем пользы, сделать так, чтобы вы предоставляли типы именно и только там, где вам нужно. Не могли бы вы посмотреть на это и сказать, в чем проблема? Почему лучше иметь четыре аргумента типа для pipe, а не по одному аргументу типа для каждого merge? Я действительно изо всех сил пытаюсь понять, почему в моем решении есть проблема. В TS нет «точных типов», и никакие ваши действия не гарантируют отсутствие лишних свойств.

jcalz 09.08.2024 21:18

(см. предыдущий комментарий) Я имею в виду, что если я поиграю с типами, я остановлюсь на чем-то вроде этого , что, кажется, настолько хорошо, насколько я могу себе представить с TypeScript. Вы либо не указываете типы, и в этом случае вы получаете все выведенное за вас, либо вы указываете аргументы типа для merge, чтобы ничего лишнего не прокралось, если, конечно, что-то не прокрадется, что всегда возможно. Это особенность TS, см. stackoverflow.com/q/75721666/2887218, и по этому поводу сложно сделать что-то общее.

jcalz 09.08.2024 21:42

@jcalz Я бы предпочел описать типы в pipe, а не в merge, поскольку я планирую использовать типы pipe в качестве источника истины для всего потока. Кроме того, я предпочитаю абстрагировать Delta внутри merge, а не передавать его в каждый merge. Но спасибо за эти решения. Я подозреваю, что эти компромиссы должны произойти, так что они определенно пригодятся. Не могли бы вы проверить мое последнее изменение, чтобы увидеть, есть ли здесь какое-либо возможное решение?

Nick Manning 09.08.2024 23:51

Вы согласны просто утверждать, что Merge<First, Delta<First, Second>> — это Second, как показано в ссылке на игровую площадку? Это должно прояснить вывод достаточно, чтобы он работал.

jcalz 10.08.2024 02:27

@jcalz Это действительно кажется многообещающим! Меня приучили отказываться от любых утверждений, но я знаю, что эксперты говорят, что для них есть время и место. Мне было бы интересно узнать, существуют ли какие-либо сценарии, в которых это потерпит неудачу, и почему именно здесь требуется утверждение типа.

Nick Manning 10.08.2024 09:11
Зод: сила проверки и преобразования данных
Зод: сила проверки и преобразования данных
Сегодня я хочу познакомить вас с библиотекой Zod и раскрыть некоторые ее особенности, например, возможности валидации и трансформации данных, а также...
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Как заставить Remix работать с Mantine и Cloudflare Pages/Workers
Мне нравится библиотека Mantine Component , но заставить ее работать без проблем с Remix бывает непросто.
Угловой продивер
Угловой продивер
Оригинал этой статьи на турецком языке. ChatGPT используется только для перевода на английский язык.
TypeScript против JavaScript
TypeScript против JavaScript
TypeScript vs JavaScript - в чем различия и какой из них выбрать?
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Синхронизация localStorage в масштабах всего приложения с помощью пользовательского реактивного хука useLocalStorage
Не все нужно хранить на стороне сервера. Иногда все, что вам нужно, это постоянное хранилище на стороне клиента для хранения уникальных для клиента...
Что такое ленивая загрузка в Angular и как ее применять
Что такое ленивая загрузка в Angular и как ее применять
Ленивая загрузка - это техника, используемая в Angular для повышения производительности приложения путем загрузки модулей только тогда, когда они...
2
16
58
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Прямо сейчас вы ожидаете, что TypesScript сможет сопоставить тип типа (arg: Guy) => GuyWithAge с типом <F, S>(d: Delta<F, S>) => (o: F) => Merge<F, Delta<F, S>>, вызываемым по значению типа {age: number}, и на основании этого сделать вывод, что F и S будут Guy и GuyWithAge соответственно. Это слишком много для TypeScript. Хотя F появляется в этом типе довольно просто, тип S скрыт как в Delta, так и в Merge, которые реализованы с помощью условных типов .

Вывод TypeScript работает лучше всего, когда типы, которые вам нужно вывести, напрямую связаны с типами, с которыми он должен работать. В качестве первого шага я бы сказал, что если вы ожидаете, что Merge<F, Delta<F, S>> всегда будет эквивалентен S, то вам, вероятно, следует просто использовать тип <F, S>(d: Delta<F, S>) => (o: F) => S напрямую, и это даст TypeScript больше шансов правильно вывести S:

const merge =
  <F extends object, S extends object>(
    delta: Delta<F, S>,
  ) =>
    (obj: F): S => {
      const merged = {
        ...obj,
        ...delta,
      };
      return merged as any; // <-- just assert this
    };

Да, нам нужно утверждать , что {...obj, ...delta} создает значение типа S, потому что TypeScript не может проследить, как Omit и Delta работают для произвольных общих типов. По сути, это недостающая функция, описанная в microsoft/TypeScript#28884 . Существуют крайние случаи, в которых Merge<F, Delta<F, S>> и S не эквивалентны (например, когда S — это вызываемый тип), но я не буду о них здесь беспокоиться; Я бы посоветовал не беспокоиться о них до тех пор, пока они не появятся в тех случаях, которые вас интересуют.

В любом случае, как только мы внесем это изменение, вы получите тот вывод, который ищете:

type Guy = { name: string };
type GuyWithAge = Guy & { age: number };
type GuyWithAgeAndJob = GuyWithAge & { job: string };
type GuyWithAgeAndJobAndMarried = GuyWithAgeAndJob & { married: boolean };    
const guy = pipe<Guy, GuyWithAge, GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>(
  { name: "Nick" },
  merge(({ age: 34 })), // okay
  merge({ job: "programmer" }), // okay
  merge({ married: true }) // okay
);

Это ответ на заданный вопрос, и он отвечает потребностям человека, задающего вопрос. Будущие читатели могут иметь разные варианты использования:

Наличие merge() require F и S заранее, когда вы вызываете его, только присваивая ему значение типа Delta<F, S>, является ограничением, поскольку это очень затрудняет вывод. Если вы вызовете merge({a: 123}) независимо от pipe(), вы получите такой тип, как (x: object) => object, что бесполезно. В целом более полезным было бы, чтобы функция была непосредственно универсальной по типу входных данных, а возвращаемая функция была бы непосредственно универсальной по типу входных данных. Благодаря этому логический вывод и поток управления работают вместе, а не борются друг с другом:

const merge =
  <U extends object>(delta: U) =>
    <T extends object>(obj: T) => {
      const merged = { ...obj, ...delta };
      return merged as Merge<T, U>;
    };

Теперь TypeScript нужно будет делать выводы только непосредственно из сайтов вызовов. Если вы вызовете merge({a: 123}), вы получите возвращаемый тип <T extends object>(obj: T) => Merge<T, {a: number}>, что позволяет вам повторно использовать его несколько раз и получать несколько выходных данных. И тогда при вызове pipe() вообще не нужно будет указывать типы вручную:

const x = pipe(
  { a: 123 },
  merge({ b: "xyz" }),
  merge({ c: true }),
  merge({ d: new Date() })
)
/* const x: {
  a: number;
  b: string;
  c: boolean;
  d: Date;
} */

Если вы хотите указать типы вручную, вы можете это сделать, но это не обязательно. И опять же, автора этого вопроса, по-видимому, не волнует этот вариант использования, и его гораздо больше беспокоит избыточная проверка свойств, которая происходит только в некоторых случаях в TypeScript и является довольно хрупкой, см. Принудительно проверять тип объекта, созданного путем распространения. Однако будущим читателям могут быть полезны более простые типы, показанные здесь.

Детская площадка, ссылка на код

Другие вопросы по теме