Привіт, мене звати Сергій. Працюю 4 роки як Front End програміст, здебільшого з TypeScript. Ця стаття буде присвячена Union типам. У цій статті, я спробую показати коли краще ужити Union, як правильно і не правильно з ними проацювати. Більшість прикладів, які я описуватиму - взяті із запитань на StackOverflow, тобто скоріш за все будуть корисні з практичної точки зору. Прошу вибачення наперед, що використовуватиму англійські слова, оскільки не завжди можу підібрати правильний переклад.
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'
}
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"Як бачимо, TypeScript залишив тільки ті ключі, які є спільними. Така логіка є по замовчуванню. Я знаю про що ви думаєте. То як все ж таки зробити так, що TS дозволив використання name ключа? Є один спосіб.
https://stackoverflow.com/questions/65805600/struggling-with-building-a-type-in-ts#answer-65805753
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'.
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 в Intersetcio та в 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 добре працює з Records, але не з 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. Якщо ви знаєте інші способи, буду радий, якщо поділитесь досвідом. Дякую за увагу