TypeScript Type Merging - PullRequest
       101

TypeScript Type Merging

2 голосов
/ 22 марта 2020

У меня есть случай, когда я хочу «объединить» типы, когда объединение типов по умолчанию (например, T | U или T & U) не достигает того, что я хочу.

Что я пытаюсь сделать это глубокое и интеллектуальное слияние типов, которое автоматически помечает свойства как необязательные во время слияния и выполняет глубокие слияния интерфейсов / типов TypeScript.

В качестве примера предположим, что у нас есть типы A и B.

type A = {
  a: string;
  b: number;
  c: boolean;
  d: {
    a2: string;
    b2: number;
  };
  e?: number;
};

type B = {
  a: string;
  b: boolean;
  d: {
    a2: string;
    c2: boolean;
  };
};

Я ищу функцию Merge, которая бы принимала 2 обобщенных c типа

type Merge<T, U> = ?????;

Затем, если используется для типов A и B, вывод будет следующим:

type AB = {
  a: string;
  b: number | boolean;
  c?: boolean;
  d: {
    a2: string;
    b2?: number;
    c2?: boolean;
  };
  e?: number;
};

. Как видно из рисунка, тип Merge будет выполнять следующие логические операции c:

  1. Если свойство существует в обоих T и U и является идентичным типом, пометьте его как обязательный и задайте тип в обоих T / U (например, что произошло со свойством a).
  2. Если свойство существует как в T, так и в U, но это другой тип, отметьте его как обязательный и установите в объединение t ype, если это примитив (например, что произошло со свойством b) или выполнить рекурсивное слияние, если это объект (например, что произошло со свойством d).
  3. Если свойство существует для одного типа но не другое, пометьте свойство как необязательное и присвойте ему тип, в котором оно было во входном типе, где оно действительно существовало (например, то, что произошло со свойством c, а также b2 и c2).
  4. Если свойство уже является необязательным в одном типе, оно должно быть необязательным для типа вывода с существующими выше правилами, применяемыми для определения его значения (например, что произошло со свойством e)

Предположим, что вы можете использовать рекурсивные условные типы, хотя я признаю, что они официально еще не поддерживаются и не должны использоваться в производстве. Я могу сделать развернутую версию, похожую на решение jcalz @ здесь для производственных случаев.

Вот игровая площадка , созданная для вопроса, с которым нужно проверить.

1 Ответ

2 голосов
/ 25 марта 2020

TLDR: Волхвы c! Попробуйте Playground

Итак, это сложный вопрос. Не столько из-за требований к слиянию, сколько из-за крайних случаев. Получение низко висящих фруктов заняло <20 минут. Чтобы убедиться, что это работает везде, понадобилось еще пару часов ... и утроил длину. Союзы сложно! </p>

  1. Что такое необязательное свойство? В { a: 1 | undefined, b?: 1 } является a необязательным свойством? Некоторые люди говорят да. Другие нет. Лично я включаю в дополнительный список только b.

  2. Как вы справляетесь с профсоюзами? Каков вывод Merge<{}, { a: 1} | { b: 2 }>? Я думаю, что тип, который имеет больше всего смысла, это { a?: 1 } | { b?: 2 }. А как насчет Merge<string, { a: 1 }>? Если вас не волнует профсоюзы, это легко ... если вы это делаете, тогда вы должны рассмотреть все это. (Что я выбрал в паренах)

    1. Merge<never, never> (never)
    2. Merge<never, { a: 1 }> ({ a?: 1 })
    3. Merge<string, { a: 1 }> (string | { a?: 1 })
    4. Merge<string | { a: 1 }, { a: 2 }> (string | { a: 1 | 2 })

Давайте разберемся с этим типом, начиная с помощников.

У меня было намек, как только я подумал о профсоюзах, что этот тип станет сложным. У TypeScript нет хорошего встроенного способа проверки равенства типов, но мы можем написать вспомогательный тип, который вызывает ошибку компилятора, если два типа не равны.

(Примечание. Тип Test может быть улучшено, это может позволить передавать типы, которые не эквивалентны, но этого достаточно для нашего использования здесь, оставаясь при этом довольно простым)

type Pass = 'pass';
type Test<T, U> = [T] extends [U]
    ? [U] extends [T]
        ? Pass
        : { actual: T; expected: U }
    : { actual: T; expected: U };

function typeAssert<T extends Pass>() {}

Мы можем использовать этот помощник следующим образом:

// try changing Partial to Required
typeAssert<Test<Partial<{ a: 1 }>, { a?: 1 }>>();

Далее нам понадобятся два типа помощников. Один, чтобы получить все необходимые ключи объекта, и один, чтобы получить дополнительные ключи. Во-первых, несколько тестов, чтобы описать, что мы ищем:

typeAssert<Test<RequiredKeys<never>, never>>();
typeAssert<Test<RequiredKeys<{}>, never>>();
typeAssert<Test<RequiredKeys<{ a: 1; b: 1 | undefined }>, 'a' | 'b'>>();

typeAssert<Test<OptionalKeys<never>, never>>();
typeAssert<Test<OptionalKeys<{}>, never>>();
typeAssert<Test<OptionalKeys<{ a?: 1; b: 1, c: undefined }>, 'a'>>();

Здесь нужно отметить две вещи. Во-первых, *Keys<never> - это never. Это важно, потому что мы будем использовать эти помощники в объединениях позже, и если объект never, он не должен предоставлять никаких ключей. Во-вторых, ни один из этих тестов не включает проверки объединения. Учитывая, как я сказал, что союзы были, это может вас удивить. Однако эти типы используются только после того, как все союзы распределены, поэтому их поведение там не имеет значения (хотя, если вы включите их в свой проект, возможно, вы захотите взглянуть на указанное поведение, оно отличается от того, что вы, вероятно, ожидаете от RequiredKeys из-за того, как написано)

Эти типы проходят указанные проверки:

type OptionalKeys<T> = {
    [K in keyof T]-?: T extends Record<K, T[K]> ? never : K;
}[keyof T;

type RequiredKeys<T> = {
    [K in keyof T]-?: T extends Record<K, T[K]> ? K : never;
}[keyof T] & keyof T;

Пара замечаний по этому поводу:

  1. Использование -? для удалите необязательные свойства, это позволяет нам избежать использования оболочки Exclude<..., undefined>
  2. T extends Record<K, T[K]>, поскольку { a?: 1 } делает не расширяет { a: 1 | undefined }. Я прошел несколько итераций, прежде чем окончательно остановился на этом. Вы также можете обнаружить опциональность с другим сопоставленным типом, как jcalz здесь .
  3. В версии 3.8.3 TypeScript может правильно сделать вывод, что тип возвращаемого значения OptionalKeys назначается на keyof T , Однако он не может обнаружить то же самое для RequiredKeys. Пересечение с keyof T исправляет это.

Теперь, когда у нас есть эти помощники, мы можем определить еще два типа, которые представляют вашу бизнес-логику c. Нам нужны RequiredMergeKeys<T, U> и OptionalMergeKeys<T, U>.

type RequiredMergeKeys<T, U> = RequiredKeys<T> & RequiredKeys<U>;

type OptionalMergeKeys<T, U> =
    | OptionalKeys<T>
    | OptionalKeys<U>
    | Exclude<RequiredKeys<T>, RequiredKeys<U>>
    | Exclude<RequiredKeys<U>, RequiredKeys<T>>;

И некоторые тесты, чтобы убедиться, что они ведут себя так, как ожидалось:

typeAssert<Test<OptionalMergeKeys<never, {}>, never>>();
typeAssert<Test<OptionalMergeKeys<never, { a: 1 }>, 'a'>>();
typeAssert<Test<OptionalMergeKeys<never, { a?: 1 }>, 'a'>>();
typeAssert<Test<OptionalMergeKeys<{}, {}>, never>>();
typeAssert<Test<OptionalMergeKeys<{ a: 1 }, { b: 2 }>, 'a' | 'b'>>();
typeAssert<Test<OptionalMergeKeys<{}, { a?: 1 }>, 'a'>>();

typeAssert<Test<RequiredMergeKeys<never, never>, never>>();
typeAssert<Test<RequiredMergeKeys<never, {}>, never>>();
typeAssert<Test<RequiredMergeKeys<never, { a: 1 }>, never>>();
typeAssert<Test<RequiredMergeKeys<{ a: 0 }, { a: 1 }>, 'a'>>();

Теперь, когда они у нас есть, мы можем определить слияние двух объектов, игнорируя примитивы и союзы на данный момент. Это вызывает тип верхнего уровня Merge, который мы еще не определили для обработки примитивов и объединений членов.

type MergeNonUnionObjects<T, U> = {
    [K in RequiredMergeKeys<T, U>]: Merge<T[K], U[K]>;
} & {
    [K in OptionalMergeKeys<T, U>]?: K extends keyof T
        ? K extends keyof U
            ? Merge<Exclude<T[K], undefined>, Exclude<U[K], undefined>>
            : T[K]
        : K extends keyof U
        ? U[K]
        : never;
};

(я не писал здесь специфических c тестов, потому что они у меня были на следующий уровень вверх)

Нам нужно обрабатывать как союзы, так и не-объекты. Давайте обрабатывать объединения объектов дальше. Как уже говорилось ранее, нам нужно распределить по всем типам и объединить их по отдельности. Это довольно просто.

type MergeObjects<T, U> = [T] extends [never]
    ? U extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : [U] extends [never]
    ? T extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : T extends any
    ? U extends any
        ? MergeNonUnionObjects<T, U>
        : never
    : never;

Обратите внимание, что у нас есть дополнительные проверки для [T] extends [never] и [U] extends [never]. Это связано с тем, что never в распределительном предложении похож на for (let i = 0; i < 0; i++), он никогда не войдет в "тело" условного выражения и поэтому вернет never, но нам нужно только never, если обоих типы never.

Мы почти у цели! Теперь мы можем обрабатывать объединяющиеся объекты, что является самой сложной частью этой проблемы. Осталось только обработать примитивы, что мы можем сделать, просто сформировав объединение всех возможных примитивов и исключив примитивы из типов, передаваемых в MergeObjects.

type Primitive = string | number | boolean | bigint | symbol | null | undefined;

type Merge<T, U> =
    | Extract<T | U, Primitive>
    | MergeObjects<Exclude<T, Primitive>, Exclude<U, Primitive>>;

И с этим типом мы сделанный! Merge ведет себя как угодно, всего около 50 строк безкомментированного безумия.

Последнее замечание о произведенных типах:

Результирующий тип из Merge сейчас верен, но это не так читабельно, как могло бы быть. Прямо сейчас при наведении курсора на результирующий тип будет отображаться пересечение и внутренние объекты с обернутыми вокруг них Merge вместо отображения результата. Мы можем исправить это, введя тип Expand, который заставляет TS расширять все в один объект.

type Expand<T> = T extends Primitive ? T : { [K in keyof T]: T[K] };

Теперь просто измените MergeNonUnionObjects для вызова Expand. Где это необходимо, это несколько проб и ошибок. Вы можете поиграть с включением или нет, чтобы получить отображение типа, которое работает для вас.

type MergeNonUnionObjects<T, U> = Expand<
    {
        [K in RequiredMergeKeys<T, U>]: Expand<Merge<T[K], U[K]>>;
    } & {
        [K in OptionalMergeKeys<T, U>]?: K extends keyof T
            ? K extends keyof U
                ? Expand<Merge<
                    Exclude<T[K], undefined>,
                    Exclude<U[K], undefined>
                >>
                : T[K]
            : K extends keyof U
            ? U[K]
            : never;
    }
>;

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...