Пошаговое руководство о том, как в 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 | 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
аргументов вместо двух?
Я оставлю этот материал для следующей статьи, но вы можете посмотреть мой рабочий черновик здесь).
Примечание переводчика
Это мой первый опыт перевода. Убедительная просьба об опечатках, запятых и просто косноязычных фразах писать в личку.