Завдання

Реалізувати тип 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]

Посилання