Пошаговое руководство о том, как в TypeScript написать такой generic-тип, который объединяет произвольные вложенные key-value структуры.
Примечание переводчика: я намерено не стал переводить некоторые слова (вроде generic, key-value), т.к., на мой взгляд, это только усложнит понимание материала.
TLDR:
Исходный код для DeepMergeTwoTypes
будет в конце
статьи. Скопируйте его в вашу IDE, чтобы поиграть с ним.
Как это выглядит в vsCode:
Если вы не уверены в своих познаниях о том, как работают generic-и в TypeScript, вы можете ознакомиться с этой статьёй (Mniminalist 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 & Bconst 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 | U
PRO 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
аргументов
вместо двух?
Я оставлю этот материал для следующей статьи, но вы можете посмотреть мой рабочий черновик здесь).
Примечание переводчика
Это мой первый опыт перевода. Убедительная просьба об опечатках, запятых и просто косноязычных фразах писать в личку.