Pull to refresh

Typescript: Объединение типов в глубину

Reading time6 min
Views26K
Original author: Jakub Švehla

Пошаговое руководство о том, как в 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 аргументов вместо двух?

Я оставлю этот материал для следующей статьи, но вы можете посмотреть мой рабочий черновик здесь).

Примечание переводчика

Это мой первый опыт перевода. Убедительная просьба об опечатках, запятых и просто косноязычных фразах писать в личку.

Tags:
Hubs:
+13
Comments28

Articles