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

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт, мене звати Сергій. Працюю 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. Якщо ви знаєте інші способи, буду радий, якщо поділитесь досвідом. Дякую за увагу!

👍НравитсяПонравилось9
В избранноеВ избранном8
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>

Дякую. Бо я використовував backticks ``` ))

генеричною

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

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

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

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

Добавлю пару докладов по арифметическим операциям над типами:
— Проектирование предметной области на TypeScript в функциональном стиле / Сергей Черепанов (FSD) www.youtube.com/watch?v=cT-VOwWjJJs
— Продвинутый TypeScript / Михаил Башуров (Syncro) www.youtube.com/watch?v=m0uRxCCno00

Для 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
```
Сподіваюсь, цей крок зрозумілий. Завтра допишу наступні

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