Я создаю симулятор, в котором объект шаг за шагом преобразуется с помощью различных функций, используя канал из 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() нет места, из которого можно было бы вывести First или Second, поскольку она не может инвертировать тип Delta, чтобы получить пару входных данных из выходных. Вместо этого я бы предложил сделать merge()generic прямо во входных данных, как показано в этой ссылке на игровую площадку. Это полностью решает вопрос? Если да, то я напишу ответ с объяснением; если нет, то что мне не хватает?
@Калет, не мог бы ты поделиться ссылкой на игровую площадку, чтобы показать, как эти типы реализованы?
@jcalz да, ты прав насчет Дельты. Я надеялся, что машинописный текст каким-то образом сможет определить первое/второе откуда-то еще, но я не был уверен, как это сделать. Ваше решение довольно хорошее - оно позволяет передавать дополнительные свойства в слияние, но это не имеет большого значения. Я так понимаю, идеального решения не существует?
У меня только что pipe кастинг на PipeResult: детская площадка
«Дополнительные» свойства означают что? Без минимального воспроизводимого примера , демонстрирующего то, что вы считаете несовершенством, я понятия не имею. –
@jcalz Я просто имею в виду, что на вашей игровой площадке я могу добавить больше свойств к каждому аргументу, передаваемому для слияния. Например, измените merge({age: 34}) на ({age: 34, extraproperty: true}), и я не получу ошибку. Вот площадка с моим кодом, чтобы можно было сравнить. В моем примере я получаю сообщение об ошибке при добавлении дополнительных свойств для слияния, как и следовало ожидать.
@Caleth Моя цель — исключить (вывести) типы, передаваемые в merge(), а не в Pipe(). Посмотрите мою игровую площадку в моем комментарии к jcalz. Я могу удалить типы, переданные pipe, но не каждому merge вызову.
Однако дополнительные свойства не являются ошибками типа. Мне трудно представить, почему вам нужна ошибка типа, особенно если вы не хотите вручную прописывать типы GuyWithAge и GuyWithAgeAndJob везде. Если вы хотите записать эти типы, то вы можете это сделать, это просто немного другой тип. Итак, это все еще не «идеально» или мне следует продолжить?
@jcalz Мне нравится, что здесь появляется ошибка «Литерал объекта не допускает дополнительных свойств». Я пытаюсь использовать типы как источник истины для структуры объекта, и я не хочу, чтобы было легко добавлять дополнительные свойства непосредственно в merge(). Я понимаю, почему мне не следует придавать этому большого значения, но можно ли удовлетворить эту просьбу? Я могу заставить его работать по ссылке на игровую площадку, но мне кажется излишним указывать типы для слияния, потому что они уже переданы в канал.
@jcalz Суть моего вопроса такова: если вы посмотрите на мою ссылку на игровую площадку, то кажется излишним, что мне все еще приходится передавать типы merge, хотя я уже передаю их pipe. В настоящее время я пытаюсь понять, почему я считаю, что это возможно — возможно, это ошибка в моем мышлении или в том, как работает машинописный текст.
Лишние свойства не являются ошибками типа; о них предупреждают только в определенных обстоятельствах, и почти наверняка больше хлопот, чем пользы, сделать так, чтобы вы предоставляли типы именно и только там, где вам нужно. Не могли бы вы посмотреть на это и сказать, в чем проблема? Почему лучше иметь четыре аргумента типа для pipe, а не по одному аргументу типа для каждого merge? Я действительно изо всех сил пытаюсь понять, почему в моем решении есть проблема. В TS нет «точных типов», и никакие ваши действия не гарантируют отсутствие лишних свойств.
(см. предыдущий комментарий) Я имею в виду, что если я поиграю с типами, я остановлюсь на чем-то вроде этого , что, кажется, настолько хорошо, насколько я могу себе представить с TypeScript. Вы либо не указываете типы, и в этом случае вы получаете все выведенное за вас, либо вы указываете аргументы типа для merge, чтобы ничего лишнего не прокралось, если, конечно, что-то не прокрадется, что всегда возможно. Это особенность TS, см. stackoverflow.com/q/75721666/2887218, и по этому поводу сложно сделать что-то общее.
@jcalz Я бы предпочел описать типы в pipe, а не в merge, поскольку я планирую использовать типы pipe в качестве источника истины для всего потока. Кроме того, я предпочитаю абстрагировать Delta внутри merge, а не передавать его в каждый merge. Но спасибо за эти решения. Я подозреваю, что эти компромиссы должны произойти, так что они определенно пригодятся. Не могли бы вы проверить мое последнее изменение, чтобы увидеть, есть ли здесь какое-либо возможное решение?
Вы согласны просто утверждать, что Merge<First, Delta<First, Second>> — это Second, как показано в ссылке на игровую площадку? Это должно прояснить вывод достаточно, чтобы он работал.
@jcalz Это действительно кажется многообещающим! Меня приучили отказываться от любых утверждений, но я знаю, что эксперты говорят, что для них есть время и место. Мне было бы интересно узнать, существуют ли какие-либо сценарии, в которых это потерпит неудачу, и почему именно здесь требуется утверждение типа.






Прямо сейчас вы ожидаете, что 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 и является довольно хрупкой, см. Принудительно проверять тип объекта, созданного путем распространения. Однако будущим читателям могут быть полезны более простые типы, показанные здесь.
Я могу заставить трубку получить
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