Розбираємось з Union-типами в TypeScript

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт, мене звати Сергій. Працюю 4 роки як Front-end програміст, здебільшого з TypeScript. Ця стаття буде присвячена Union типам. У цій статті, я спробую показати коли краще ужити Union, як правильно і неправильно з ними працювати. Більшість прикладів, які я описуватиму — взяті із запитань на Stack Overflow, тобто скоріш за все будуть корисні з практичної точки зору. Прошу вибачення наперед, що використовуватиму англійські слова, оскільки не завжди можу підібрати правильний переклад.

Unions

В багатьох випадках краще використати Union замість Enum. По-перше тільки для того, що Union не займає місця. По друге Enums мають свої недоліки.
Приклад:

enum Foo {
  a = 'a',
  b = 'b'.
}

type Bar = 'a' | 'b'

Дуже часто Unions стають в нагоді, коли треба написати типи до React компонентів. Наприклад, щоб зробити illegal states unrepresentable.

const enum Messages {
    Success = 'Success',
    Failure = 'Failure'
}

const enum PromiseState {
    Pending = 'Pending',
    Fulfilled = 'Fulfilled',
    Rejected = 'Rejected',
}

Як зробити так, щоб наш колега не зміг створити наступний обєкт ?

const state = {
    valid: true,
    error: Messages.Success,
    state: PromiseState.Rejected // не можна дозволяти Rejected коли valid: true
}

Дуже просто, необхідно створити Union:

interface Failure {
    valid: false;
    error: Messages.Failure;
    state: PromiseState.Rejected
}

interface Success {
    valid: true;
    error: Messages.Success;
    state: PromiseState.Fulfilled
}

type ResponseState = Failure | Success;

Розглянемо наступний приклад:

type A = { name: string };

type B = { name: string; age: number }

type Union = A | B

const test = (a: Union) => {
  const name = a.name // ok
  const age = a.age // error
}

Чому a.name працює, а a.age ні ? Тому що, TS в цьому випадку дозволяє тільки ті ключі (keys/props), які є спільними для всіх union. Давайте перевіримо чи це правда.

type Keys = keyof Union; // "name"

Як бачимо — так. Така логіка є по замовчуванню. Я знаю про що ви думаєте. То як все ж таки зробити так, що TS дозволив використання name ключа? Є один спосіб.

interface Props1 {
  nameA: string;
  nameB?: string;
}

interface Props2 {
  nameB: string;
}

type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> = 
    T extends any 
    ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

type StrictUnion<T> = StrictUnionHelper<T, T>

type Props = StrictUnion<Props1 | Props2>

type Keys = keyof Props // "nameA" | "nameB"

То які Union краще використовувати? На мою думку, краще використовувати ті, які мають спільний ключ для всіх випадків. Наприклад:

type A = {
  type: 'A',
  name: string
}

type B = {
  type: 'B',
  age: number
}

type Union = A | B;

const test = (union: Union) => {
  if (union.type === 'A') {
    const result = union; // A
  }

  if (union.type === 'B') {
    const result = union; // B
  }
}

А що тоді робити в випадку коли не всі ключі є required , кращим способом буде використання typeguards.

type A = {
 type: 'A',
 name: string
}

type B = {
 age: number
}

type Union = A | B;

const isA = (arg: Record<string, unknown>): arg is A => Object.prototype.hasOwnProperty.call(arg, 'type') && arg.type === 'A'
const isB = (arg: Record<string, unknown>): arg is B => Object.prototype.hasOwnProperty.call(arg, 'B')

declare var union: Union;

if (isA(union)) {
 const result = union; // A
}

Ви скажете, що я читаю ваші думки, але я вже бачу Ваше незадоволення тим, що я порушив DRY. Я це зробив навмисне, щоб плавно перейти до generic typeguard. Закладаю, що дуже частими є перевірки на предмет існування того чи іншого ключа. Наприклад:

const theme={} as Record<string, unknown>
if(theme.color){}
if(!!(theme.color)){}
if(Boolean(theme.color)){}

Чи можна написати функцію в TS, яка буде водночас генеричною і не буде ламати нам типів? Можна:

type A = {
  type: 'A',
  name: string
}

type B = {
  age: number
}

type Union = A | B;

declare var union: Union;

const hasProperty = <T, Prop extends string>(obj: T, prop: Prop): obj is T & Record<Prop, unknown> =>
  Object.prototype.hasOwnProperty.call(obj, prop)

if (hasProperty(union, 'type')) {
  const result = union; // A
}

if (!hasProperty(union, 'type')) {
  const result = union; // B
}

Ми дещо відхилилися від нашої теми. Іноді існує необхідність з’єднати (злити, merge) всі Union в один тип. Я маю на увазі intersection. Це не є тривіальне завдання, та все ж таки його можна виконати. Розглянемо наступний допоміжний тип:

type A = {
  name: string
}

type B = {
  age: number
}

//https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type Result = UnionToIntersection<A | B> // A & B

На перший погляд це доволі складний тип. Та що там казати, й на другий погляд він не є простішим. Щоб зрозуміти краще, що тут робиться, давайте розглянемо наступний приклад:

type Intersection<T> = T extends {
  a: (x: infer A) => void;
  b: (x: infer A) => void
} ? A
  : never;

type Base = {
  a: (x: { prop_a: string }) => void;
  b: (x: { prop_b: number }) => void
};

type Result = Intersection<Base>

Детальний опис, як працює UnionToIntersection ви можете знайти в посиланні. Гаразд, нам вдалося злити два типи в один. Але чи є спосіб дізнатися чи обслуговуваний тип є взагалі Union ? Звичайно що існує, інакше б я змовчав і не задавав такого питання. Отже існує 2 способи.

// Перший
// https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union#comment-94748994
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true
//Другий, 
//посилання загубив, але можна переглянути тут https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types щоб зрозуміти цей дивний синтаксис з квадратними дужками

type IsUnion<T, Y = true, N = false, U = T> = U extends any
    ? ([T] extends [U] ? N : Y)
    : never;

Ви вже мабуть з нудьги переключились на закладку з YouTube чи Facebook. В такому випадку, тримайте приклад перетворення Union в Array:

//https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type/50375286#50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

//https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468114901
type UnionToOvlds<U> = UnionToIntersection<
  U extends any ? (f: U) => void : never
>;

type PopUnion<U> = UnionToOvlds<U> extends (a: infer A) => void ? A : never;

//https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union#comment-94748994
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;

type UnionToArray<T, A extends unknown[] = []> = IsUnion<T> extends true
  ? UnionToArray<Exclude<T, PopUnion<T>>, [PopUnion<T>, ...A]>
  : [T, ...A];

interface Person {
  name: string;
  age: number;
  surname: string;
  children: number;
}

type Result = UnionToArray<keyof Person>; // ["name", "age", "surname", "children"]

const func = <T,>(): UnionToArray<keyof T> => null as any;

const result = func<Person>(); // ["name", "age", "surname", "children"]

Як бачимо, з наших допоміжних типів можна створити справді дуже складні. Підніміть руку ті хто уживає Object.keys(). Ліс рук :))

const obj = {
  age: 42,
  name: 'John'
}

const keys = Object.keys(obj)// string[]

Чи дуже нам допомагає тип string[]? Ні. Ми б хотіли, як мінімум Array<'age'|'name'>.

type Keys = Array<keyof typeof obj> // ("age" | "name")[]

Але і цей тип не є найкращим, тому що в цьому випадку const arr: Keys = ['name', 'name'] TS не кричить на нас. Підозрюю, що ви очікуєте щось на кшталт ['name', 'age']. Але будьласка майте на увазі, що ми не можемо гарантувати порядок ключів. Ніхто не може (тут мені згадалась цитата з Доні Браско), навіть специфікація JS не може. Отже нам потрібно створити Union: ['name', 'age'] | ['age', 'name'].

const obj = {
  age: 42,
  name: 'John'
}

//https://twitter.com/WrocTypeScript/status/1306296710407352321
type TupleUnion<U extends string, R extends any[] = []> = {
	[S in U]: Exclude<U, S> extends never ? [...R, S] : TupleUnion<Exclude<U, S>, [...R, S]>;
}[U];

type keys = TupleUnion< keyof typeof obj>;

Ми вже знаємо, як перетворити Union в Intersetcion та в Array, але досі не знаємо як ітерувати через колекцію Union. Давайте візьмемо Union в якому зовсім немає спільних ключів.

type A = { name: string }
type B = { age: number }
type C = { surname: string }

type Union = A | B | C

type Result = { [P in keyof Union]: P } // {}

//Тип Result буде пустим обєктом, але ми вже знаємо чому. Також, ми вже навіть знаємо як заставити TS повернути всі ключі, а не тільки спільні для всіх Union.

type A = { name: string }
type B = { age: number }
type C = { surname: string }

type Union = A | B | C;

type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> = 
    T extends any 
    ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

type StrictUnion<T> = StrictUnionHelper<T, T>

type Result = { [P in keyof StrictUnion<Union>]: P } 

Це вже набагато краще. Ми вже знаємо як ітерувати, тепер ми зможемо пройтись по кожному Union, додати будь-який ключ і повернути оновлений Union. Тобто змапувати Union.

type Result = { [P in keyof StrictUnion<Union>]: Extract<Union, { [W in P]: number | string }> & { kind: P } }

Тепер виходить цікава ситуація. Ми маємо на виході об’єкт, а очікуємо Union. Більше того, значення, які нас цікавлять є вартостями ключів (Key/Value). То як нам повернути всі вартості об’єкту? На допомогу нам прийде дуже простий і водночас дуже дієвий helper тип: type Values<T> = T[keyof T]. Цей тип власне повертає усі Values у формі Union.

type UnionResult = Values<Result>

Як бачимо, ми ще маємо undefined в нащому Union. Тож давайте його позбудемось.

type UnionResult = Exclude<Values<Result>, undefined>

Як ви вже мабуть здогадались, Values добре працює з Record, але не з Array. Щоб повернути Union усіх значень таблиці, необхідно використати наступний helper: type Values<T extends unknown[]> = T[number]. За допомогою простої ітерації по Union можна також сплющити вкладені Unions (flatten nested union types). Щсь вам приклад:

// https://stackoverflow.com/questions/66116836/how-to-flatten-nested-union-types-in-typescript
type Union =
    | {
        name: "a";
        event:
        | { eventName: "a1"; payload: string }
        | { eventName: "a2"; payload: number };
    }
    | {
        name: "b";
        event: { eventName: "b1"; payload: boolean };
    };

type nested = {
    [n in Union['name']]: {
        [e in Extract<Union, { name: n }>['event']['eventName']]: {
            name: n,
            eventName: e,
            payload: Extract<Union['event'], {eventName: e}>['payload']
        }
    }
}
// https://stackoverflow.com/a/50375286/3370341
type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type r = UnionToIntersection<nested[keyof nested]>
type Result = r[keyof r]

type Expected =
    | { name: "a"; eventName: "a1"; payload: string }
    | { name: "a"; eventName: "a2"; payload: number }
    | { name: "b"; eventName: "b1"; payload: boolean };

declare const expected: Expected;
declare const result: Result;

const y: Result = expected
const l: Expected = result

Як бачимо, існує безліч операцій які ми можемо виконувати на Union types. Якщо ви знаєте інші способи, буду радий, якщо поділитесь досвідом. Дякую за увагу!

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному9
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

if (hasProperty(union, 'type')) {
  const result = union; // A
}

if (!hasProperty(union, 'type')) {
  const result = union; // B
}

Підозрюю, що тут помилка в другій умові і має бути:


if (!hasProperty(union, 'age')) {
  const result = union; // B
}

Ні, перегляньте тут.
Наведіть мишкою на перший і другий result. Власне йде мова про те, що type проперті не існує в В типі

Дякую. Щось я зовсім неуважний і не помітив логічне заперечення в другій умові :(

Для блоків коду краще явно вказувати мову програмування:

<div class="hl-wrap html"><pre>
</pre></div>

<div class="hl-wrap bash"><pre>
</pre></div>

<div class="hl-wrap text"><pre>
</pre></div>

<div class="hl-wrap javascript"><pre>
</pre></div>

<div class="hl-wrap typescript"><pre>
</pre></div>
генеричною

Я би взяв для перекладу щось з перший варіантів Гугл Транслейта: загальний, узагальнений та навіть родовий звучить приємніше, ніж генеричний :)

в мире программирования есть устоявшееся определение: дженерик, если скажут: общий, обобщенный, генеричный — то это вообще не понятно о чем.

Теж правда. Але у той же час якщо переклад, то не хотілось би, щоб він складався з тих самих термінів англійською, тільки написаних кирилицею. Якщо існують відповідники.

Ви праві, я намагався деякі слова не перекладати і залишити в оригіналі. Проблема в тому, що я ніколи не працював програмістом в Україні. Дуже мало працював із співвітчизниками. Навіть про той самий TypeScript я не зміг найти багато матеріалу української мовою, тому й вирішив написати статтю. Можливо хтось знайде її корисною для себе, можливо ні. Я навіть не мав української розкладки на клавіатурі. Обовязково візьму до уваги усю критику і напишу наступну статтю про роботу з callbacks. Я намагався висвітлити найбільш поширені питання з якими стикався на stackoverflow.

Добавлю пару докладов по арифметическим операциям над типами:
— Проектирование предметной области на TypeScript в функциональном стиле / Сергей Черепанов (FSD)
— Продвинутый TypeScript / Михаил Башуров (Syncro)

Для React разработчиков есть отличный гайд с множеством примеров использования TS: react-typescript-cheatsheet.netlify.app/...​hooting/types/#enum-types

Также есть хорошая библиотека utility-types что-бы не изобретать велосипед: github.com/piotrwitek/utility-types

Автор, вам треба краще попрацювати над текстом, він важко читається. Невже при читанні наступного речення, у вас не виникає бажання переформулювати його?

По-перше тільки для того, що Union не займає місця.

Очевидно, що тут краще написати «Хоча б для того, щоб не займати місце у скомпільованому файлі.».

По друге Enums мають свої недоліки.

Якщо ви пишете про недоліки, то покажіть ці недоліки.

Ось це взагалі незрозуміло про що:

Дуже часто Unions стають в нагоді, коли треба написати типи до React компонентів. Наприклад, щоб зробити illegal states unrepresentable.
const enum Messages {
    Success = 'Success',
    Failure = 'Failure'
}

const enum PromiseState {
    Pending = 'Pending',
    Fulfilled = 'Fulfilled',
    Rejected = 'Rejected',
}

Що ви цим хотіли сказати? Якщо ваша стаття із акцентом на використанні TypeScript, то поясніть щоб було зрозуміло і TypeScript-розробнику, а не тільки React-розробнику (хоча думаю і йому буде складно щось тут зрозуміти).

Далі не читав, бо далі така ж жесть...

Ну что-бы текст был понятный и лаконичный нужно тренироваться, много тренироваться, не вижу ничего зазорного что-бы тренироваться написанием статей на Dou.

Спасибо, открыл для себя много нового. Буду ещё перечитывать, потому что не всё сразу удаётся понять.

Дякую. Можливо можу допомогти із зрозумінням

Понимаете, у меня плохо получается схватывать какую-то идею, пока я не понимаю что у неё в реализацией.
Вот вы начали с простых типов, дальше интерфейсы с пересекающимися ключами, там где

type Union = A | B

. Всё ± понятно и логично.
И тут, ВНЕЗАПНО, конструкция

type UnionKeys = T extends T ? keyof T : never;
type StrictUnionHelper =
T extends any
? T & Partial, keyof T>, never>> : never;

type StrictUnion = StrictUnionHelper

Ок, я понимаю что это в принципе базовые TS операторы и хелперы. Но на самом деле тяжело вот так вот взять и понять, как эта штуковина расширяет юнион...
Получается как с рисованием совы, если вы понимаете.
Поэтому я и написал, что буду перечитывать — буду брать вот эти конструкции и парсить их, обложившись www.typescriptlang.org

Для початку:
1) ```type UnionKeys = T extends T ? keyof T : never```;
Щоб краще зрозуміто про що тут йде мова можете переглянути документацію до Distributive condition types www.typescriptlang.org/...​ibutive-conditional-types
Спробуйте також застосувати квадратні дужки:```type UnionKeys = [T] extends [T] ? keyof T : never;```
Якщо ми використовуємо `extends` з `generic` на виході получаємо Розподільні умовні типи (google translate)
Згідно документації, якщо ми використаємо Union тип в місці для generic’a разом з extends то умовний тип, в нашому випадку, буде застосований до всіх Union. Тобто нф виході будемо мати:
```
// псевдо код
type UnionKeys = T extends T ? keyof A | keyof B : never
```
Сподіваюсь, цей крок зрозумілий. Завтра допишу наступні

Підписатись на коментарі