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