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

Original author: Jakub Švehla
  • Translation

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

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

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

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

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 28

    –11
    Отличный пример того, что TypeScript не справляется с банальными задачами :)
      +5

      Можно пример того, как это делается в других языках? :)


      Насколько я понимаю, в языках без структурной типизации даже сама постановка такой задачи невозможна.

        –10
        В Java, C# это же просто
        class C implements A, B
        

        или я чего-то не понимаю?
        Здесь же проба понятия того, что это за тип, отобьёт желание дальше читать код
          +5

          И в чём вы видите связь между между наследованием интерфейсов в классах и объединением произвольных вложенных структур данных? Вы понимаете разницу между структурной типизацией и номинативной? А ещё вы точно уверены в том, что знаете что такое "рекурсия"?


          Ваш пример на Typescript выглядит символ в символ точно также. Единственная проблема — ваш пример никак не связан с темой статьи.

            –1
            Рекурсия? Расскажи :)
              0
              Ну если совсем простым языком, то эта задача похожа решение задачи по глубокому объединению объектов в js (deepMerge), только здесь объединяются не js объекты, а ts типы. Эта задача решается с помощью рекурсии.
        +4

        Отличный пример поверхностного комментария человека, который решил не заморачиваться с погружением в статью, и совсем не знает темы

          –2

          argumentum ad hominem, так сказать


          Убеди меня, что LOC для решения задачи адекватно самой задаче

        +1
        Спасибо за статью! Недавно столкнулся с более сложной версией задачи. Надо объединить типы объектов, которые находятся в массиве для описания результата работы такой функции:
        const result = merge([{k1: 1, k2: 2}, {k3: 3, k4: 4}, {k5: 5}])
        

        В библиотечке ts-toolbelt есть подходящий дженерик. Было бы здорово увидеть статью с примером и разбором решения такой задачи.
          0
          Было бы здорово увидеть статью с примером и разбором решения такой задачи.


          1-ая иконка справа сразу после аватарки :) Я серьёзно, если вы разобрались в том, как этот generic из ts-toolbelt работает — напишите статью. Нужно больше годных статей про Typescript. Подавляющее большинство typescript-программистов очень сильно "плавают" во всём, что выходит за рамки самых простых задач. Больше хороших статей — больше годных типов.


          Очень рекомендую блог от Dr. Alex Rauschmayer. У него классные разборы всяких typescript нюансов.

          0
          В то время как мы ожидали чего-то вроде этого:

          type ExpectedType = {
            key1: string | null,
            key2: string,
            key3: string
          }

          Да как бы нет. С чего бы пересечение должно давать вдруг объединение? То, что хочет автор реализуется куда проще:


          type DeepMerge< A, B > =
              & Partial< Omit< A, keyof B > >
              & Partial< Omit< B, keyof A > >
              & {
                  [ key in keyof (A|B) ]: DeepMerge< A[key], B[key] >
              }
            +1
            То, что хочет автор реализуется куда проще:

            Я тоже вначале такое написал. Почти символ в символ. Но нет. Задача более сложная. Он там отдельно рассматривает массивы, фильтрует объекты, обрабатывает nullable.

              0

              Ну ладно, чуть сложнее:


              type DeepMerge< A, B > =
                  (A|B) extends {} ?
                      & Partial< Omit< A, keyof B > >
                      & Partial< Omit< B, keyof A > >
                      & (A|B)
                      & { [ key in keyof (A|B) ]: DeepMerge< A[key], B[key] > }
                  : (A|B)
                0

                Нет. Контр-пример:


                type A = { a: { c: null } };
                type B = { a: string };

                У автора ['a'] даст { c: null } | string
                А у вас получится каша:


                a.a = 'qwe';
                a.a = { c: null }; // error

                В целом & (A|B) было слишком дерзко :)

                  0

                  Это я забыл убрать. Ну ок, пустой интерфейс был слишком прост.


                  type DeepMergeTwoTypes< A, B > =
                      (A|B) extends Record< string, unknown > ?
                          & Partial< Omit< A, keyof B > >
                          & Partial< Omit< B, keyof A > >
                          & { [ key in keyof (A|B) ]: DeepMergeTwoTypes< A[key], B[key] > }
                      : (A|B)
                    0

                    У вас по-разному обрабатываются массивы. У автора будет (string | number)[], а у вас string[] | number[]. Впрочем ваша версия мне кажется более логичной.


                    (A | B) extends Record<string, unknown>

                    А вот тут хорошо. Кажется получается тоже самое, но без tuple.

                  0

                  А ещё похоже, что манёвр с extends {} не работает. Во всяком случае в v4.0.5.
                  Судите сами:


                  type L<A> = A extends {} ? 'obj' : 'none-obj';
                  type L1 = L<string> // 'obj'
                  
                  type N<A> = A extends { [key: string]: unknown } ? 'obj' : 'none-obj';
                  type N1 = N<string> // non-obj
                  type N2 = N<{}> // obj
                    0

                    Вообще, было бы проще, если бы автор написал типотесты, как тут.

              0

              Делал подобную вещь с рекурсией, когда мне надо было вывести тип объектов хоста (Java) с которыми скрипт работал напрямую. Чтоб не возникало желание напрямую присваивать JS объекты с тем же интерфейсом, кроме примитивов.

                0
                TS — это почти JS, только типы указывать надо, говорили они… :)
                Написание типов для TS — это, похоже, отдельная профессия
                  +5

                  Типы в тс — это отдельный по настоящему функциональный язык программирования.

                    0

                    К сожалению, это не совсем так. У вывода типов в TS есть ограничение на глубину рекурсивного спуска — то ли 50, то ли 52 шага, не помню точно, — после чего возвращается any. Отсутствие HKT тоже не добавляет радости — те костыли, которые я описывал, нельзя назвать удобным для повседневного использования решением. Но если говорить в общем, то система типов в строгом подмножестве TS достаточно неплоха среди мейнстримных ЯП.

                      0

                      Что именно "не совсем так"? Это не мощный, не удобный, и даже не согласованный язык, но вполне себе фп. А вот то, что вы там описываете — это как раз фп на честном слове ('мамой клянусь в глобальные переменные не лажу').

                        0

                        Пожалуйста, покажите, где в моей статье есть хоть слово про глобальные переменные. И в статье, и в комментарии выше я писал исключительно про систему типов тайпскрипта.
                        ФП без типов высшего порядка — это только первая ступень в лестнице абстракции, по сути — simply typed lambda calculus, самая нижняя вершина в лямбда-кубе Барендрегта. И даже до STLC система типов TS не дотягивает — в частности, из-за ограничения на глубину рекурсивного спуска. До версии 2.6 она была даже (почти) Тьюринг-полной, и могла с большой натяжкой считаться функциональным ЯП. В третьей ветке команда разработки компилятора затянула гайки по возможности хаков компилятора, так что система типов TS это уже не совсем язык программирования как таковой. Хотя, признаюсь честно, мне хотелось бы иметь возможность писать тайплевел-функции на подобии type families в хаскеле, но расширение системы типов не является ценностью для проекта TypeScript.

                          0
                          Пожалуйста, покажите, где в моей статье есть хоть слово про глобальные переменные

                          Я думаю nin-jin имел ввиду глобальный lookup интерфейс, а не переменную.

                      0
                      У вывода типов в TS есть ограничение на глубину рекурсивного спуска — то ли 50, то ли 52 шага

                      Кстати, не знаете, они планируют это поменять, или так будет всегда?

                        0

                        Тут не подскажу, к сожалению — такие факты обычно всплывают где-нибудь в GitHub Issues (например), и в публичном роадмапе нечасто появляются. Если этот вопрос интересует, то есть смысл его задать в тех же Issues, команда разработки обычно достаточно доброжелательно относится. Я больше переживал про HKT, про которые им регулярно напоминают тут, но подвижек к имплементации нет.

                Only users with full accounts can post comments. Log in, please.