[TypeScript] Typescript: Объединение типов в глубину (перевод)

Автор Сообщение
news_bot ®

Стаж: 6 лет 8 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
09-Ноя-2020 03:31

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

===========
Автор оригинала: Jakub Švehla
===========
Похожие новости: Теги для поиска: #_typescript, #_type_inference, #_generics, #_types, #_typescript
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 01-Ноя 07:23
Часовой пояс: UTC + 5