Проблема

Реализовать тип IsUnion, который принимает тип параметр T и возвращает true, если T это объединение типов. Например:

type case1 = IsUnion<string>; // false
type case2 = IsUnion<string | number>; // true
type case3 = IsUnion<[string | number]>; // false

Решение

Когда я увидел эту проблему, не знал с чего даже начать. Потому что нету решения в TypeScript, которое можно использовать для реализации такого типа. Нету никаких встроенных типов, которые бы хоть как-то помогли.

Поэтому, самое время включать креативное мышление и использовать подручные средства. Начнём с того, что подумаем об объединениях типов и как они представлены.

Когда вы указываете плоский тип, например string, то значениями этого типа не будет ничего другого, только string. Но, если вы указываете объединение типов, например string | number, то получаете возможные значения как string, так и number.

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

И эту ключевую точку мы и возьмем за фактор, как можно определить объединение типов. Когда мы дистрибутивно перебираем по типу T, который не является объединением, это ничего не меняет. Но, это меняет многое для объединений типов.

У TypeScript есть одна возможность языка - дистрибутивные условные типы. Когда вы пишете конструкцию T extends string ? true : false, где T это объединение, TypeScript применит условный тип к каждому элементу из объединения дистрибутивно. Грубо говоря, это будет выглядеть вот так.

type IsString<T> = T extends string ? true : false;

// Например, передаем параметр T = string | number
// Дистрибутивное применение условного типа будет выглядеть как-то так
type IsStringDistributive = string extends string
  ? true
  : false | number extends string
  ? true
  : false;

Видите к чему я хочу подвести? Если тип T это объединение, используя дистрибутивные условные типы можно разбить его и сравнить с входным тип параметром T. В случае, если они одинаковые - это не было объединением. Но, если они не будут одинаковыми, значит это объединение. Потому что string не сравнится с string | number и точно так же number не сравнится с string | number.

Давайте уже начнём с реализации! Сначала, сделаем копию входного параметра T, чтобы сравнивать с изначальным объединением без изменений.

type IsUnion<T, C = T> = never;

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

type IsUnion<T, C = T> = T extends C ? never : never;

Теперь, важная часть - сравнить элемент из объединения с изначальным входным тип параметром T. В случае, если эти типы будут одинаковыми, это значит что в обеих случаях было по одному элементу - не объединение. Иначе, дистрибутивные условные типы сделали дело и мы сравниваем один элемент из объединения с изначальным объединением - значит это объединение.

type IsUnion<T, C = T> = T extends C ? ([C] extends [T] ? false : true) : never;

Готово! Чтобы прояснить момент сравнения, посмотрим, какие типы держат в себе [C] и [T] внутри дистрибутивного условного типа.

Когда мы передаем тип, не являющийся объединением, например string, они держат одинаковые типы. Следовательно, это не объединение, возвращаем false.

[T] = [string][C] = [string];

Но, если передаём объединение, например string | number, они держат разные типы. Наша копия C держит в себе кортеж с нашим изначальным объединением, а T держит в себе объединение из кортежей. Следовательно, типы разные, считаем это объединением и возвращаем true.

[T] = [string] | [number]
[C] = [string | number]

Что почитать