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