Пошаговое руководство о том, как в TypeScript написать такой generic-тип, который объединяет произвольные вложенные key-value структуры.
Примечание переводчика: я намерено не стал переводить некоторые слова (вроде generic, key-value), т.к., на мой взгляд, это только усложнит понимание материала.
TLDR:
Исходный код для DeepMergeTwoTypes будет в конце статьи. Скопируйте его в вашу IDE, чтобы поиграть с ним.
Как это выглядит в vsCode:
Если вы не уверены в своих познаниях о том, как работают generic-и в TypeScript, вы можете ознакомиться с этой статьёй (Miniminalist Typescript - Generics)
Если вы хотите проверить корректность кода просто скопируйте его в вашу IDE (прим. переводчика: или в TypeScript Playground песочницу).
Disclaimer
Используя код из этой статьи в production вы делаете это на свой страх и риск (тем не менее, мы его используем).
Проблема поведения &-оператора в Typescript
Для начала посмотрим на проблему объединения типов. Определим два типа A и B и новый тип C, который является результатом объединения A & B
type A = { key1: string, key2: string }
type B = { key2: string, key3: string }
type C = A & B
const a = (c: C) => c.Всё выглядит замечательно до тех пор, пока вы не начнёте объединять несовместимые типы данных.
type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
type C = A & BТип A определяет key2 как строку, в то время как в типе B это null.
Typescript выводит это объединение несовместимых типов как never и тип C просто перестаёт работать. В то время как мы ожидали чего-то вроде этого:
type ExpectedType = {
key1: string | null,
key2: string,
key3: string
}Пошаговое решение
Давайте начнём с создания generic-типа, который будет рекурсивно объединять типы Typescript. Для начала мы определим 2 вспомогательных generic-типа.
GetObjDifferentKeys<>
type GetObjDifferentKeys<T, U> = Omit<T, keyof U> & Omit<U, keyof T>Этот тип принимает на входе 2 объекта и возвращает новый объект, содержащий только уникальные ключи из A и B.
type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
type C = GetObjDifferentKeys<A, B>['']GetObjSameKeys<>
В противовес предыдущему generic-у объявим другой тип, который вытащит все ключи, которые есть в обоих объектах.
type GetObjSameKeys<T, U> = Omit<T | U, keyof GetObjDifferentKeys<T, U>>Возвращаемый тип — объект.
type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
type C = GetObjSameKeys<A, B>Все вспомогательные типы готовы, так что мы можем приступать к реализации нашего главного generic-типа DeepMergeTwoTypes
DeepMergeTwoTypes<>
type DeepMergeTwoTypes<T, U> =
// "не общие" (уникальные) ключи - опциональны
Partial<GetObjDifferentKeys<T, U>>
// общие ключи - обязательны
& { [K in keyof GetObjSameKeys<T, U>]: T[K] | U[K] }Этот generic находит все "не общие" ключи между объектами T и U, и сделает их опциональными (необязательными). Спасибо за это стандартному типу Partial<>, из стандартной библиотеки типов Typescript. Этот тип с опциональными ключами объединяется (посредством &-оператора) с объектом содержащим все общие ключи между T и U , значением которых будут T[K] | U[K].
Посмотрите на пример ниже. Новый generic нашёл "не-общие" ключи и сделал их опциональными (?), в то время как остальные ключи строго обязательны.
type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
const fn = (c: DeepMergeTwoTypes<A, B>) => c.Но наш DeepMergeTwoTypes generic не работает рекурсивно со вложенными структурами. Так что давайте вынесем объединение объектов в новый generic тип MergeTwoObjects и будем вызывать DeepMergeTwoTypes рекурсивно до тех пор, пока он не объединит все вложенные структуры.
// этот generic рекурсивно вызывает DeepMergeTwoTypes<>
type MergeTwoObjects<T, U> =
// "не общие" (уникальные) ключи - опциональны
Partial<GetObjDifferentKeys<T, U>>
// общие ключи - обязательны
& {[K in keyof GetObjSameKeys<T, U>]: DeepMergeTwoTypes<T[K], U[K]>}
export type DeepMergeTwoTypes<T, U> =
// проверяем являются ли типы массивами, распаковываем и запускаем рекурсию
[T, U] extends [{ [key: string]: unknown }, { [key: string]: unknown } ]
? MergeTwoObjects<T, U>
: T | UPRO TIP: Обратите внимание на то, что в DeepMergeTwoTypes используется if-else условие (extends ?:) Мы проверяем что и T и U удовлетворяют условию, засунув их в кортеж (tuple) [T, U]. Это поведение похоже на &&-оператор в Javascript.
Этот generic проверяет, что оба параметра соответствуют типу { [key: string]: unknown } (это Object). Если это так, то он объединяет их посредством MergeTwoObject<>. Этот процесс рекурсивно повторяется для всех вложенных объектов.
Примечание переводчика: Проверка на extends { [key: string]: unknown } позволяет отфильтровать все не-объекты, т.е. строки, числа, booleans и т.д..
И вуаля! Теперь наш generic рекурсивно применён ко всем вложенным объектам. Пример:
type A = { key: { a: null, c: string} }
type B = { key: { a: string, b: string} }
const fn = (c: MergeTwoObjects<A, B>) => c.key.На этом всё?
Увы, нет. Наш новый generic не поддерживает массивы.
Прежде, чем мы продолжим, мы должны понять ключевое слово infer (to infer - выводить).
infer смотрит на структуру данных и вытаскивает её тип (в нашем случае это массив). Подробнее почитать про infer можно здесь (Type inference in conditional types).
Пример использования infer. Здесь мы получаем тип отдельно взятого элемента массива (Item):
export type ArrayElement<A> = A extends (infer T)[] ? T : never
// Item === (number | string)
type Item = ArrayElement<(number | string)[]>Теперь мы можем добавить поддержку массивов, просто добавив эти две строки, в которых мы выводим тип значений элементов массива. И рекурсивно вызываем DeepMergeTwoTypes для содержимого массивов.
export type DeepMergeTwoTypes<T, U> =
// ----- 2 добавленные строки ------
// эта ?
[T, U] extends [(infer TItem)[], (infer UItem)[]]
// ... и эта ?
? DeepMergeTwoTypes<TItem, UItem>[]
: ... rest of previous generic ... Сейчас DeepMergeTwoTypes может рекурсивно вызывать сам себя, в случае если значения это объекты или массивы.
type A = [{ key1: string, key2: string }]
type B = [{ key2: null, key3: string }]
const fn = (c: DeepMergeTwoTypes<A, B>) => c[0].И это работает! На этом всё?
Эх... Нет. Последняя проблема заключается в объединении Nullable типов с non-nullable.
type A = { key1: string }
type B = { key1: undefined }
type C = DeepMergeTwoTypes<A, B>['key']Ожидаемый тип — string | undefined, но на деле это не так. Давайте добавим ещё две строки в нашу цепочку if-else .
export type DeepMergeTwoTypes<T, U> =
[T, U] extends [(infer TItem)[], (infer UItem)[]]
? DeepMergeTwoTypes<TItem, UItem>[]
: [T, U] extends [{ [key: string]: unknown}, { [key: string]: unknown } ]
? MergeTwoObjects<T, U>
// ----- 2 добавленные строки ------
// эта ?
: [T, U] extends [
{ [key: string]: unknown } | undefined,
{ [key: string]: unknown } | undefined
]
// ... и эта ?
? MergeTwoObjects<NonNullable<T>, NonNullable<U>> | undefined
: T | UПроверяем объединение nullable значений:
type A = { key1: string }
type B = { key1: undefined }
const fn = (c: DeepMergeTwoTypes<A, B>) => c.key1;И... Вот теперь всё!
Мы сделали это! Значения корректно объединяются даже для nullable , вложенных объектов и массивов.
Давайте опробуем наш generic на более сложных данных:
type A = { key1: { a: { b: 'c'} }, key2: undefined }
type B = { key1: { a: {} }, key3: string }
const fn = (c: DeepMergeTwoTypes<A, B>) => c.Полный исходный код:
/**
* Принимает 2 объекта T и U и создаёт новый объект, с их уникальными
* ключами. Используется в `DeepMergeTwoTypes`
*/
type GetObjDifferentKeys<T, U> = Omit<T, keyof U> & Omit<U, keyof T>
/**
* Принимает 2 объекта T and U и создаёт новый объект с их ключами
* Используется в `DeepMergeTwoTypes`
*/
type GetObjSameKeys<T, U> = Omit<T | U, keyof GetObjDifferentKeys<T, U>>
type MergeTwoObjects<T, U> =
// "не общие" ключи опциональны
Partial<GetObjDifferentKeys<T, U>>
// общие ключи рекурсивно заполняются за счёт `DeepMergeTwoTypes<...>`
& { [K in keyof GetObjSameKeys<T, U>]: DeepMergeTwoTypes<T[K], U[K]> }
// объединяет 2 типа
export type DeepMergeTwoTypes<T, U> =
// проверяет являются ли типы массивами, распаковывает их и
// запускает рекурсию
[T, U] extends [(infer TItem)[], (infer UItem)[]]
? DeepMergeTwoTypes<TItem, UItem>[]
// если типы это объекты
: [T, U] extends [
{ [key: string]: unknown},
{ [key: string]: unknown }
]
? MergeTwoObjects<T, U>
: [T, U] extends [
{ [key: string]: unknown } | undefined,
{ [key: string]: unknown } | undefined
]
? MergeTwoObjects<NonNullable<T>, NonNullable<U>> | undefined
: T | U
// тестируем:
type A = { key1: { a: { b: 'c'} }, key2: undefined }
type B = { key1: { a: {} }, key3: string }
const fn = (c: DeepMergeTwoTypes<A, B>) => c.keyПоследний штрих
Как бы так поправить DeepMergeTwoTypes<T, U> generic, чтобы он мог принимать N аргументов вместо двух?
Я оставлю этот материал для следующей статьи, но вы можете посмотреть мой рабочий черновик здесь).
Примечание переводчика
Это мой первый опыт перевода. Убедительная просьба об опечатках, запятых и просто косноязычных фразах писать в личку.
8gen
Отличный пример того, что TypeScript не справляется с банальными задачами :)
faiwer Автор
Можно пример того, как это делается в других языках? :)
Насколько я понимаю, в языках без структурной типизации даже сама постановка такой задачи невозможна.
8gen
В Java, C# это же просто
или я чего-то не понимаю?
Здесь же проба понятия того, что это за тип, отобьёт желание дальше читать код
faiwer Автор
И в чём вы видите связь между между наследованием интерфейсов в классах и объединением произвольных вложенных структур данных? Вы понимаете разницу между структурной типизацией и номинативной? А ещё вы точно уверены в том, что знаете что такое "рекурсия"?
Ваш пример на Typescript выглядит символ в символ точно также. Единственная проблема — ваш пример никак не связан с темой статьи.
8gen
Рекурсия? Расскажи :)
vruzanov
Ну если совсем простым языком, то эта задача похожа решение задачи по глубокому объединению объектов в js (deepMerge), только здесь объединяются не js объекты, а ts типы. Эта задача решается с помощью рекурсии.
fillpackart
Отличный пример поверхностного комментария человека, который решил не заморачиваться с погружением в статью, и совсем не знает темы
8gen
argumentum ad hominem, так сказать
Убеди меня, что LOC для решения задачи адекватно самой задаче