Вичерпні масиви в Typescript та їхня роль для розробників
Всім привіт! Мене звати Владислав, я Front-end Developer в OBRIO, продуктовій ІТ-компанії з екосистеми Genesis. Я активно вивчаю сам та просуваю в команді Advanced TypeScript скіли та знання. У цьому тексті хочу розповісти про вичерпні масиви (exhaustive array), розібрати одну прикладну задачу, в якій виникає саме поняття вичерпного масиву, та проблему, яку він вирішує. Ми пройдемо шлях від постановки проблеми та формулювання задачі до покрокового пошуку рішення з поясненнями.
У статті будуть використовуватися наступні терміни та їхні синоніми:
- Union — позначається через | (pipe). Синонім: обʼєднання.
- Intersection — позначається через &. Синонім: перетин.
- Tuple — масив фіксованої довжини з фіксованими типами елементів. Синоніми: кортеж, вектор.
- У контексті цієї статті терміни «enum», «константний обʼєкт» або скорочено «обʼєкт» можна вважати синонімами.
- Елементи union та Intersection будуть називатися елементами або конституентами — це повні синоніми.
Навмисно опускаю визначення терміну exhaustive array і надам його в кінці статті: під час розкриття теми це визначення буде сформовано автоматично.
Постановка проблеми та пошук рішення
Отже, у чому суть проблеми. На зображенні — дропдаун із переліком мов, які можна обрати для локалізації користувацького контенту. Його створено через Material UI, схожі компоненти використовуються в мільйонах інших проєктах незалежно від фреймворку.
Подивимося на деякі деталі реалізації цього дропдауну: у коді визначено enum Language з переліком мов у проєкті. Як приклад для статті я обрав маленький enum Language, проте в реальному проєкті можна зустріти enum з великою кількістю елементів, необов’язково мов.
enum Language { EN = 'en', ES = 'es', FR = 'fr', DE = 'de', PT = 'pt', JA = 'ja', TR = 'tr', }
Нижче — фрагмент коду компонента дропдауну LanguageSelect: всередині нього визначаються ще дві сутності:
- обʼєкт languageDisplayNameMap, який містить імена мов, що будуть відображатись у дропдауні;
- масив languageOptions, який визначає порядок мов у дропдауні.
Обидві сутності певним чином повʼязані з enum Language і визначаються через нього.
const languageDisplayNameMap = { [Language.EN]: 'English', [Language.FR]: 'French', [Language.ES]: 'Spanish', [Language.DE]: 'German', [Language.PT]: 'Portuguese', [Language.JA]: 'Japanese', [Language.TR]: 'Turkish', } as const satisfies Record<Language, string>; const languageOptions = [ Language.EN, Language.FR, Language.ES, Language.DE, Language.PT, Language.JA, Language.TR, ] as const satisfies Language[];
Уявімо, що нам потрібно додати нову мову до проєкту. Для цього ми підемо редагувати enum Language і додамо в нього код нової мови. Проблема полягає в тому, що у разі здійснення такої операції у компоненті LanguageSelect також необхідно відредагувати ці дві сутності. Проте повідомлення про помилку, що нової мови не вистачає, згенерує лише languageDisplayNameMap, а масив languageOptions не покаже жодної помилки. Це логічно, адже тип Language[] просто вказує, що це довільний масив з елементів enum Language.
Отже, масив languageOptions не є type-safe, і ми маємо памʼятати про те, що у разі змін, необхідно не забути й про цей масив.
Що конкретно ми хочемо від languageOptions:
- Щоб усі його елементи були з Language.
- Щоби довжина цього масиву дорівнювала довжині (кількості конституент) enum Language.
Звичайна типізація Language[] вирішує тільки перший пункт. Для реалізації другої вимоги необхідно створити новий тип. Назвемо його EnumArray<E>, і скажемо, що він має задовольняти дві умови: щоби усі його елементи були з E, а довжина цього масиву дорівнювала кількості елементів в enum E.
Почнімо пошук рішення.
Крок 1. Усе є обʼєктом
Перший крок, який я здійснив під час пошуку рішення — подивився на першу сутність languageDisplayNameMap і запитав себе: а чому для неї TypeScript генерує помилку? Відповідь була така: бо Record<Language, string> робить перевірку, чи усі ключі з Language присутні в обʼєкті. Зʼявилася гіпотеза: чи можна тоді наш масив languageOptions виразити через Record? Якщо так, то через який?
На допомогу прийшло знання з базового JavaScript, у якому все є обʼєктом. Масив — це просто обʼєкт із числовими ключами та властивістю length. Тому я замінив Language[] на Record<number, Language>, і трапилося диво — TS не видав помилки.
const languageOptions = [ Language.EN, Language.FR, Language.ES, Language.DE, Language.PT, Language.JA, Language.TR, ] as const satisfies Record<number, Language>; //No errors suddenly;
Це означає, що масив можна затипізувати як Record<number, Language>, тобто привласнити в цей Record. Далі я розвинув своє рішення і замінив number в Record на Union чисел від 0 до 6, тобто явно вказав усі індекси в цьому масиві. TS знову не згенерував жодної помилки.
const languageOptions = [ Language.EN, Language.FR, Language.ES, Language.DE, Language.PT, Language.JA, Language.TR, ] as const satisfies Record<0 | 1 | 2 | 3 | 4 | 5 | 6, Language>;
Далі я додав ще літерал 7, щоб імітувати додавання нового елементу в enum Language, і тоді TS згенерував помилку — показав, що властивості з ключем 7 (тобто восьмої мови) не вистачає в цьому масиві.
const languageOptions = [ Language.EN, Language.FR, Language.ES, Language.DE, Language.PT, Language.JA, Language.TR, ] as const satisfies Record<0 | 1 | 2 | 3 | 4 | 5 | 6 | 7, Language> // Raises an error;
Таким чином, якщо ми знайдемо спосіб динамічно будувати цей union чисел від 0 до N-1, де N — це довжина enum Language, то ми вирішимо завдання.
type ObjectValues<O> = O[keyof O]; type EnumArray<E> = Record</* 0 | 1 | 2 | ... | N - 1, where N is the length of E */any, ObjectValues<E>>
Дисклеймер:
ObjectValues<O> — це простий тайп-хелпер, який дає отримати union з типів значень певного літерала обʼєкта або enum.
Отже, ми дізналися, що тип EnumArray<E> необхідно виразити через Record, де ключі — це числа від 0 до N-1 (N — довжина enum E), а значення — відповідні елементи enum E. Фактично нам необхідно перетворити обʼєкт в union чисел від 0 до N-1, де N — кількість ключів в обʼєкті.
Крок 2. Вчимося рахувати
Пропоную на другому етапі створити тип CountTo<N>, який буде повертати union від 0 до N-1. Якби ми реалізовували таку логіку в JavaScript, ми 100% звернулися б до циклів або рекурсії. Type-Level в TypeScript — це окрема функціональна мова програмування, в якій недоступні цикли, проте є рекурсія. Щоразу, коли в TypeScript виникає задача реалізувати якийсь ітераційний алгоритм на рівні типів, вам знадобляться рекурсивні типи.
Рекурсія справедливо сприймається розробниками як дещо складне, принаймні через те, що рекурсивний код важко читати та статично аналізувати. В мене немає для вас добрих новин: у Typescript рекурсивні типи виглядають ще гірше через синтаксичну «естетику» мови. Проте саму рекурсію насправді написати не так важко: для цього потрібен принаймні один ground case та власне сам рекурсивний виклик. Спробуємо це зробити.
Для створення рекурсивних типів потрібен другий допоміжний аргумент для зберігання результату попередньої репетиції (в контексті рекурсії використовується саме термін «репетиція» замість «ітерації»):
export type CountTo<N extends number, R = []> = R;
N — це літерал числа, до якого ми будемо рахувати, а R — це кортеж, в якому будемо зберігати проміжні та фінальні результати.
Почнімо з того, що напишемо ground case рекурсії (умову, за якої рекурсія завершується):
export type CountTo<N extends number, R extends number[] = []> = R['length'] extends N ? R : never;
Якщо довжина R дорівнює N, то рекурсію можна завершувати та повертати R.
export type CountTo<N extends number, R extends number[] = []> = R['length'] extends N ? R : CountTo<N, [...R, R['length']]>;
Якщо не дорівнює, то викликаємо CountTo знову, проте вже передаємо не пустий кортеж, а розпилюємо в нього те, що є в R, а в кінець додаємо поточне значення довжини R (на першій репетиції це буде 0).
Отже, ми отримаємо кортеж з числами [0 | 1 | ... | N — 1]. Проте нам потрібен не кортеж, а union, тому замість того, щоб повертати просто R, повернемо R[number]:
export type CountTo<N extends number, R extends number[] = []> = R['length'] extends N ? R[number] : CountTo<N, [...R, R['length']]> type test = CountTo<5>; // 0 | 1 | 2 | 3 | 4
Вийшло! У такий спосіб ми декомпозували попередній union від 0 до N-1 на 2 типи CountTo<ObjectLength<E>>, і створили перший з них:
type EnumArray<E> = Record<CountTo<ObjectLength<E>>, ObjectValues<E>>
Крок 3. Довжина об’єкта
Наступний крок — створюємо тип ObjectLength, який повертатиме довжину об’єкта, тобто кількість його ключів. Далі ми не будемо працювати саме з початковим обʼєктом, бо для вирахування довжини нам потрібен лише union ключів. Напишемо тип UnionLength<U>, а ObjectLength можна виразити через UnionLength та оператор keyof.
Як ми зрозуміли з попереднього кроку, числова константа (літерал) на рівні типів є лише у кортежів у якості довжини. Тобто для її отримання необхідно перетворити цей union в кортеж і взяти його довжину:
type UnionLength<U> = Tuple<U>['length'];
Отже, ми зрозуміли, що надалі можемо працювати не з початковим обʼєктом, а лише з union його ключів, а також звели задачу до створення типу Tuple<U>, що з Union U створить нам кортеж.
Дисклеймер:
Ще з JS нам відомо, що ключі в обʼєкті не впорядковані, а в runtime ми не можемо покладатись на порядок ключів. В TS так само: хоча задача перетворення union в кортеж і вирішується, важливо розуміти, що порядок є негарантованим, тому цей тип не можна використовувати для type-safe типізації в runtime. Але як проміжний тип для нашої задачі він безпечний, бо для нас важливіша довжина, яка, звісно, не залежить від порядку.
Крок 4. Overloads are not that kind as they look
Пропоную розглянути конструкцію з перевантаженою функцією:
declare function overloaded(x: string): string; declare function overloaded(x: number): number; declare function overloaded(x: boolean): null;
Сама функція — доволі проста і, на перший погляд, у ній немає нічого цікавого. Але що буде, якщо запросити в TS її тип, наприклад, через operator typeof?
declare function overloaded(x: string): string; declare function overloaded(x: number): number; declare function overloaded(x: boolean): null; type Overloaded = typeof overloaded;
Ми отримали обʼєкт з певними властивостями. У документації TypeScript це називається call signatures. Вони використовуються для опису обʼєктів, які можна викликати, але які також можуть мати додаткові властивості. TypeScript також використовує їх для опису типів перевантажених функцій.
TypeScript сприймає overloaded-функції як об’єкти, які можна викликати певним чином. Спосіб виклику визначається call signatures. Якщо перевантажена функція — це об’єкт із такими сигнатурами, чи можна створити її шляхом перетину трьох незалежних функцій? Для цього ми просто взяли кожну overloaded-сигнатуру окремо й зробили Intersection між ними. Очікуємо, що Overloaded2 де-факто буде еквівалентним Overloaded1.
declare function overloaded(x: string): string; declare function overloaded(x: number): number; declare function overloaded(x: boolean): null; type Overloaded = typeof overloaded; type Overloaded2 = ((x: string) => string) & ((x: number) => number) & ((x: boolean) => null);
Так і є! Вони однакові, бо привласнюються один одному. Якщо ви спробуєте викликати x2, побачите той самий auto-complete для overloaded-функцій, тобто для TypeScript це абсолютно рівноправні перевантажені функції.
declare function overloaded(x: string): string; declare function overloaded(x: number): number; declare function overloaded(x: boolean): null; type Overloaded = typeof overloaded; type Overloaded2 = ((x: string) => string) & ((x: number) => number) & ((x: boolean) => null); declare const x: Overloaded; const x2: Overloaded2 = x; //No error, they are the same!!!
Отже, на цьому етапі ми дізналися, що TypeScript представляє overloaded-функції через call signatures. Це означає, що можна на рівні типів визначити свою overloaded-функцію через call signatures або шляхом перетину callback signtatures.
Крок 5. Dark side of overloads
Тепер спробуємо зробити просту маніпуляцію з overloaded-сигнатурою. Зверніть увагу, що Overloaded тепер виражений через перетин callback signatures.
type Overloaded = ((x: string) => string) & ((x: number) => number) & ((x: boolean) => null); type Args = Overloaded extends (x: infer A) => any ? A : never;
Далі ми намагаємося отримати тип аргументів перевантаженої функції за допомогою простого умовного виразу та оператора infer. Який буде результат? Логічно припустити, що це буде union зі string | number | boolean. Кожен із цих типів можна передати як аргумент, отже, такий результат здається природним.
type Args = Overloaded extends (x: infer A) => any ? A : never; //^? = boolean
Однак, якщо навести на Args, ми побачимо просто boolean, що виглядає досить дивно. Це сталося через те, що перевантажені функції в TypeScript не є повністю type-safe. Тому будь-які спроби маніпуляцій із сигнатурами змушують їх «схлопнутись» до останньої визначеної сигнатури — так і зʼявився тип аргументу boolean.
Крок 6. Last Member of Union
Що дають нам знання, отримані на двох минулих кроках? Ми можемо створити тип LastMember<U>, який вертатиме останню конституенту Union-у U:
type Union = 'Z' | 'X' | 'S' type C = LastMember<Union> //must be 'S';
Щоби це зробити, необхідно виконати прості перетворення:
- Початковий union перетворити на обʼєднання функцій (далі псевдокод):
(Z) => 0 | (X) => 0 | (S) => 0.
- Перетворити цей union на Intersection, тобто створити перевантажену функцію: (Z) => 0 & (X) => 0 & (S) => 0.
- Зробити інференс типу його аргументу за допомогою оператора infer. Це дозволить нам отримати C.
Почнемо з першого етапу, перетворимо Z | X | S на (Z) => 0 | (X) => 0 | (S) => 0. Проте у такій реалізації нічого не вийде:
type Union = 'Z' | 'X' | 'C' type LastMember<U> = (arg: U) => 0; type Result = LastMember<Union>; //^? Result = (arg: 'Z' | 'X' | 'C') => 0;
Це створить функцію, аргументом якої буде весь union. Щоб отримати саме обʼєднання з трьох функцій, потрібно використати дистрибутивний контекст. За замовчуванням типи-параметри не мають цього контексту, тому потрібно застосувати conditional types:
type Union = 'Z' | 'X' | 'C' type LastMember<U> = U extends U ? (arg: U) => 0 : never; type Result = LastMember<Union>; //^? Result = (arg: 'Z') => 0 | (arg: 'X') => 0 | (arg: 'C') => 0
Тут ми написали умову U extends U, яка завжди вірна, лише для того, щоб створити дистрибутивний контекст. Тоді типи-параметри поводяться наступним чином (далі псевдокод):
type Result = LastMember<Union> = LastMember<'Z'> | LastMember<'X'> | LastMember<'C'>;
Це саме те, що нам потрібно. У дистрибутивному контексті тип-параметр U представляє не весь union, а кожну окрему його конституенту. Далі нам потрібно перетворити наш union в Intersection. Поки ми не розібралися, як це зробити, давайте так і залишимо:
type Union = 'Z' | 'X' | 'C' type Intersection<U> = U; type LastMember<U> = Intersection<U extends U ? (arg: U) => 0 : never>; type Result = LastMember<Union>;
Тепер у цієї перевантаженої функції необхідно просто вивести тип аргументу за допомогою infer:
type Union = 'Z' | 'X' | 'C' type LastMember<U> = Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never; type C = LastMember<Union> //must be 'C';
Отже, на цьому кроці ми створили тип LastMember<U>, який дозволяє отримувати останній елемент union-у. Цей тип використовує невідомий нам тип Intersection, про який розповім далі.
Тепер із допомогою LastMember можна нарешті побудувати Tuple.
Крок 7. Union to Tuple (almost)
Маючи LastMember<U>, створити тип Tuple<U>, який перетворюватиме union у кортеж, дуже просто. Ми просто беремо останній елемент з union і додаємо його до кортежу, повторюючи цей процес, поки всі елементи не будуть оброблені. Зробімо це.
Оскільки ми маємо роботу з повторюваним процесом, як було показано на прикладі CountTo, необхідно створити рекурсивний тип:
type Tuple<U, L = LastMember<U>> = U;
Другий опціональний параметр слугує допоміжним для зберігання проміжного результату на кожній ітерації. Початковим його значенням є остання конституента U.
Далі знову пишемо ground case:
type Tuple<U, L = LastMember<U>> = [U] extends [never] ? [] : any
Цю умову слід читати так: якщо в U не залишилось елементів, то виходимо з рекурсії. Якщо елементи ще є, то повертаємо кортеж, який містить останній елемент на поточній репетиції, а на початок додає результат виклику Tuple, але вже на U, з якого вилучили L.
type Union = 'Z' | 'X' | 'C' type LastMember<U> = Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never; type Tuple<U, L = LastMember<U>> = [U] extends [never] ? [] : [...Tuple<Exclude<U, L>>, L]
Таким чином ми задекларували тип Tuple<U> та зʼясували, що все, що нам залишилося, — це навчитися перетворювати union в Intersection (створювати тип Intersection<U>).
Крок 8. Варіантність типів
Перетворення union в Intersection не є складною задачею, але щоб воно не виглядало чорною магією, потрібно додати теорії про варіантність типів (Type Variance).
Будь-якій мові зі статичною типізацією притаманне таке явище, як варіантність типів — звʼязки між типами, які визначаються правилами привласнення одних типів іншими. Тобто який тип є супертипом, а який субтипом. Підкреслю, що варіантність — це не особливість TypeScript, а загальна характеристика будь-якої мови програмування зі статичною типізацією, де є субтипи та супертипи.
Існують такі типи варіантності: коваріантність, контрваріантність, біваріантність та інваріантність.
Почнемо з коваріантності. Розглянемо лістинг:
declare class Animal { voice(): void; }; declare class Cat extends Animal { voice(): void; run(): void; } declare const cat: Cat; const animal: Animal = cat; const anotherCat: Cat = animal; // Error! // Cat is a sub-type of Animal // i.e. Cat is assignable to Animal // but Animal is not assignable to Cat
У нас декларується клас Animal та Cat — реалізації цих класів нам наразі неважливі.
Далі декларується змінна типу Cat. Коли на 12 рядку ми намагаємося привласнити значення типу Cat змінній з типом Animal, це працює. На 13 рядку ми намагаємося зробити навпаки: привласнити значення Animal типу Cat. І це вже не працює, адже Cat є «нащадком» класу Animal, відповідно Cat можна привласнити Animal, але не навпаки.
Розширимо цей приклад, задекларуємо масиви кішок і тварин та спробуємо знову зробити те ж саме привласнення. Бачимо, що нічого не змінилось: масив кішок, як і раніше, привласнюється масиву тварин, що цілком логічно, а масив тварин масиву кішок — ні.
declare const catArray: Cat[]; const animalArray: Animal[] = catArray; const anotherCatArray: Cat[] = animalArray; // Covariance: if Cat is a sub-type of Animal, // then Cat[] is also is a sub-type of Animal[], // i.e. Array<T> is covariant over T
Це і називається коваріантність: якщо Cat є субтипом Animal та Array<Cat> є субтипом Array<Animal> (тобто його можна привласнити останньому), то кажуть, що Array<T> коваріантен над Т. При коваріантності, як ми бачимо, зберігається природний порядок привласнень субтипів своїм супертипам.
Давайте ще розширимо цей приклад:
type F<T> = (arg: T) => void; declare const c: F<Cat>; const a: F<Animal> = c; // Error! const anotherC: F<Cat> = a; // Contravariance: Cat is assignable to Animal, // but Cat => void is not assignable to Animal => void // but Animal => void is assignable to Cat => void // i.e. (arg: T) => void is contravariant over T
Задекларуємо тип F<T>, який являє собою просту функцію, тип аргументу якої є T. Оголосимо змінну c типу F<Cat>, потім оголосимо змінну a типу F<Animal> та спробуємо в неї записати значення c. Ми бачимо помилку: хоча Cat привласнюється Animal, (Cat) => void не привласнюється (Animal) => void. Проте навпаки привласнення працює: в anotherC можна записати a.
Чому так? Тому, що те, що очікує Cat, не зможе працювати з довільною твариною. Але те, що очікує Animal, зможе працювати навіть з Cat. Це і називається контрваріантність: тип (T) => void контрваріантен над типом T.
В цьому лістингу зображено приклад інваріантності типів:
type G<T> = (arg: T) => T; declare const catFunction: G<Cat>; const animalFunction: G<Animal> = catFunction; const anotherCatFunction: G<Cat> = animalFunction; // Invariance: (Cat) => Cat is not assignable to (Animal) => Animal // and (Animal) => Animal is also not assignable to (Cat) => Cat
Тут Cat є як аргументом, так і типом повернення функції. Ми бачимо, що Cat => Cat не привласнюється в Animal => Animal, бо те, що очікує кішку, не може працювати з довільною твариною. Так само навпаки: Animal => Animal не привласнюється в Cat => Cat, бо не всяка тварина є кішкою. Тобто тип (T) => T є інваріантним над T.
Біваріантність — це поєднання коваріантності та контрваріантності. Наявність біваріантності іноді свідчить про помилку в ієрархії типів. У цьому випадку вона виникає через те, що Cat насправді структурно нічим не відрізняється від свого суперкласу Animal, тому Cat і Animal є рівними. Відповідно, всі привласнення між ними працюють.
declare class Animal { voice(): void; }; declare class Cat extends Animal { // Animal is the same as Cat } declare const catArray: Cat[]; const animalArray: Animal[] = catArray; const anotherCatArray: Cat[] = animalArray; // No errors because of bivariance type F<T> = (arg: T) => void; declare const c: F<Cat>; const a: F<Animal> = c; // No errors because of bivariance const anotherC: F<Cat> = a; type G<T> = (arg: T) => T; declare const catFunction: G<Cat>; const animalFunction: G<Animal> = catFunction; const anotherCatFunction: G<Cat> = animalFunction;
Отже, що ми зрозуміли про варіантність:
- Covariance: () => Cat привласнюється () => Animal, бо те, що вертає тварину — може вертати і кішку, проте не навпаки: не всяка тварина є кішкою. Коваріантність зберігає «природний» порядок успадкування. У такому випадку ми кажемо, що Cat і Animal стоять в «коваріантній позиції».
- Contravariance: (Animal) => void привласнюється (Cat) => void, бо те, що очікує Animal, може прийняти і Cat, але не навпаки: те, що очікує кішку, не може працювати з довільною твариною. Контрваріантність інвертує «природній» порядок успадкування. Тут ми кажемо, що Cat і Animal стоять в контрваріантній позиції.
- Invariance: (Animal) => Animal не привласнюється (Cat) => Cat, бо не усі повернуті Animal є кішками, та (Cat) => Cat не привласнюється (Animal) => Animal, бо те, що очікує кішку, не може прийняти довільну тварину. Тут ми кажемо, що Cat та Animal стоять в інваріантній позиції.
- Bivariance: поєднання коваріантності та контрваріантності. Частіше свідчить про помилку.
Крок 9. Таємниця оператора infer
Ми майже на фініші! Щоб створити тип Union to Intersection, нам залишилося дізнатися одну важливу властивість оператора infer. Розглянемо приклад, у якому визначаємо тип X, що приймає довільний тип T. Якщо він задовольняє обʼєкт з властивостями a та b, то тип X поверне їхні типи.
type X<T> = T extends { a: infer U; b: infer U } ? U : never; type test1 = X<{ a: string; b: string }>; // test1 = string type test2 = X<{ a: string; b: number }>; // test2 = string | number
- На тесті № 1 ми передаємо обʼєкт, в якому a та b мають однакові типи. Очікувано, в результаті отримаємо тип string.
- На тесті № 2 ми передаємо обʼєкт, в якому a та b мають різні типи. Це вже не так очевидно, але infer виводить їх як union: string або number.
Отже, можна припустити, що коли infer одного і того самого типу зустрічається більш ніж в одному місці, infer виводить його як union.
Тепер трохи модифікуємо цей приклад. Змінимо тип X так, щоб тепер він перевіряв, чи відповідає тип Т функції, аргументом якої є обʼєкт з властивостями a та b. Якщо так, то нехай поверне ці типи.
type X<T> = T extends (x: { a: infer U; b: infer U }) => any ? U : never; type test1 = X<(x: { a: string; b: string }) => any>; // test1 = string type test2 = X<(x: { a: string; b: number }) => any>; // test2 = never 🗿🗿🗿
- Тест № 1 так само повертає string.
- Тест № 2 чомусь резолвиться в never.
Здавалося б, ми нічого суттєвого не змінили. Чому ж у другому кейсі ми отримали never, а не union зі string та number? Розглянемо наступний приклад:
type AFunction = (a: number) => void; type BFunction = (b: string) => void; type F = AFunction | BFunction; declare const f: F; f() // What to pass?
Тип AFunction визначає функцію, яка приймає string. Тип BFunction визначає функцію, яка приймає number. Далі ми визначаємо тип F, який є Union AFunction та BFunction, декларуємо функцію f типу F, і намагаємось її викликати.
Питання: з якими значеннями це можливо?
Суть у тому, що жоден з типів — ані рядки, ані числа — не задовольняє умов цієї функції. Якщо ми подивимося на тип її аргументу, то побачимо never. У цьому випадку never вже не здається чимось неприродним: ми намагаємося передати в f таке значення, яке одночасно задовольняло б і string, і number. Але таких значень не існує.
Тому TypeScript цілком справедливо виводить тип never. Ми створили функцію, яку неможливо безпечно викликати, тому з union-функціями слід бути обережними.
type AFunction = (a: number) => void; type BFunction = (b: string) => void; type F = AFunction | BFunction; declare const f: F; f('hello') f(42); //This function literaly cannot be invoked safely
Давайте трохи модифікуємо цей приклад: тепер AFunction та BFunction приймають не «голі» типи, а загорнуті в обʼєкт з властивістю a та b відповідно. Далі ми знову декларуємо функцію f, як обʼєднання функцій AFunction та BFunction. З якими значеннями тепер ми можемо викликати f?
type AFunction = (a: { a: string }) => void; type BFunction = (b: { b: number }) => void; type F = AFunction | BFunction; declare const f: F; f() // What to pass?
Ми не можемо передати просто обʼєкт, який має лише a або лише b. Проте можна передати обʼєкт, який не має ані a, ані b. Це логічно: під час виконання ми не знаємо, чи буде викликана AFunction, чи BFunction, тому передаємо значення, яке підходить для обох функцій.
І тепер, увага, магія: якщо навести на тип аргументу функції f, ми побачимо Intersection!
ВАУ! Ми щойно показали, що в TypeScript існує природний механізм, а точніше природна умова, за якої union перетворився в Intersection. І умова ця дуже проста — контрваріантність. Як ми вже показали раніше, аргументи функцій «знаходяться в контрваріантній позиції». Так само як контрваріантність інвертує природний порядок привласнень, вона змушує union «перетворюватись» в Intersection. Якщо «заінферити» тип аргументу функції f, то цей Intersection-тип також збережеться.
У чому ж полягає секрет оператора infer? У тому, що якщо тип знаходиться в коваріантній позиції, то infer виводить типи як union. Проте, якщо в контрваріантній позиції, — infer виведе його як Intersection. Саме тому, коли ми перемістили цей обʼєкт на позицію аргументу функції, тобто поставили його в контрваріантну позицію, infer почав робити інференс як Intersection. А Intersection string та number — це never, бо, як ми показали раніше, не існує сутності, яка одночасно є і string, і number.
Крок 10. Union to Intersection
Тепер у нас є все, щоби створити тип Union to Intersection: потрібно поставити union в контрваріантну позицію. Щоб поставити тип в контрваріантну позицію, достатньо зробити його аргументом функції. Спершу, як ми вже робили з LastMember<U>, необхідно union перетворити на union функцій, де аргументом кожної функції стане окрема конституента U.
type Union = { a: 'A' } | { b: 'B' } | { c: 'C' } type Intersection<U> = U extends U ? (x: U) => 0 : never type Result = Intersection<Union>; //^? Result = (x: {a: 'A'}) => 0 | (x: {b: 'B'}) => 0 | (x: {c: 'C'}) => 0
Далі необхідно зробити інференс типу аргументу цієї функції за допомогою оператора infer:
type Union = { a: 'A' } | { b: 'B' } | { c: 'C' } type Intersection<U> = (U extends U ? (x: U) => 0 : never) extends (x: infer I) => 0 ? I : never; type Result = Intersection<Union>; //^? Result = {a: 'A'} & {b: 'B'} & {c: 'C'}
Вийшло! Тепер у нас є всі інструменти для завершення типу Tuple<U> та відповідно EnumArray<E>:
type Union = 'Z' | 'X' | 'C' type Intersection<U> = (U extends U ? (arg: U) => 0 : never) extends (arg: infer I) => 0 ? I : never; type LastMember<U> = Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never; type Tuple<U, L = LastMember<U>> = [U] extends [never] ? [] : [...Tuple<Exclude<U, L>>, L] type TuplifiedUnion = Tuple<Union>; // ["Z" | "X" | "C"]
Повне рішення задачі:
export type ObjectValues<O> = O[keyof O]; type Intersection<U> = (U extends U ? (arg: U) => 0 : never) extends (arg: infer I) => 0 ? I : never; type LastMember<U> = Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never; type Tuple<U, L = LastMember<U>> = [U] extends [never] ? [] : [...Tuple<Exclude<U, L>>, L] type UnionLength<U> = Tuple<U>['length']; type CountTo<N extends number, R extends number[] = []> = R['length'] extends N ? R[number] : CountTo<N, [...R, R['length']]>; type EnumArray<E extends Record<string, any>> = Array<ObjectValues<E>> & (UnionLength<keyof E> extends number ? Record<CountTo<UnionLength<keyof E>>, ObjectValues<E>> : never) & { length: UnionLength<keyof E> }; const zxc = { z: 'Z', x: 'X', c: 'C', } as const; type ZXCArray = EnumArray<typeof zxc> const arr: ZXCArray = ['Z', 'X', 'C']
Уважні читачі могли помітити, що тут EnumArray трохи розширено: до нього додано Array<ObjectValues<E>> та {length: UnionLength<keyof E>}. Ці сигнатури були додані до типу, щоби він повністю задовольнив інтерфейс ArrayLike, та на ньому були доступні всі методи масиву.
Отже, ми:
- Створили тип Intersection<U> який перетворює union в Intersection.
- Завершили тип Tuple<U>, який перетворює union в Tuple (кортеж).
- Завершили тип EnumArray<E>, який перетворює обʼєкт E в кортеж, елементами якого можуть бути тільки значення E, а довжина масиву має дорівнювати кількості ключів E.
Рішення:
- Представили Array<T> як Record<number, T> & {length: number} & Array<T>.
- Представили number в цьому типі як CountTo<UnionLength<T>>.
- UnionLength<T> виразили як довжину кортежу Tuple<T>[‘length’].
- Виразили Tuple<T> у такий спосіб:
- A | B | X;
- (A) => 0 | (B) => 0 | (X) => 0;
- (A) => 0 & (B) => 0 & (X) => 0;
- C отримати за допомогою infer та додати в кортеж останнім елементом. Потім повторили це, але вже для Exclude<A | B | X, X>.
Висновки
Як виглядають тепер дані для нашого дропдауну:
const languageDisplayNameMap = { [Language.EN]: 'English', [Language.FR]: 'French', [Language.ES]: 'Spanish', [Language.DE]: 'German', [Language.PT]: 'Portuguese', [Language.JA]: 'Japanese', [Language.TR]: 'Turkish', } as const satisfies Record<Language, string>; const languageOptions = [ Language.EN, Language.FR, Language.ES, Language.DE, Language.PT, Language.JA, Language.TR, ] as const satisfies EnumArray<typeof Language>;
Змінилася лише типізація languageOptions. Тепер цей масив є типобезпечним: коли до Language будуть додавати або вилучати елементи-члени, то EnumArray повідомить про зміни та буде рейзити помилку, що елемента з певним індексом не вистачає, або є зайвий у кортежі.
Отже, в результаті цього експерименту ми дізналися, що тип Array<T> можна виразити через Record<number, T>. Також ми дослідили роботу рекурсивних типів на прикладі CountTo і Tuple, у створенні складних структур та розглянули особливості перевантажених функцій і їхній вплив на виведення типів аргументів за допомогою infer. Дізналися про дистрибутивність дженериків (U extends U) та варіантність типів в TS, а також створили типи Union to Intersection (Intersection<T>), Union to Array (Tuple<T>) та EnumArray<T>. Усі ці рішення реалізовано повністю на Type-Level TS без змін у runtime-логіці.
Чому в назві говориться про exhaustive array, а в самій статті ми створили тип enum array? Як повʼязані ці типи? Чи це взагалі синоніми? На початку тексту я обіцяв надати його визначення.
Вичерпний масив над Union U — це тип даних, який являє собою масив, елементами якого є конституенти Union U. Його довжина дорівнює довжині U, та він гарантує, що кожна конституента U входить до масиву один раз.
Отже, з цього визначення виходить, що:
- Звичайні масиви в TypeScript мають лише першу властивість вичерпних масивів: вони просто перевіряють, що усі елементи того типу, який треба, але нічого більше.
- EnumArray<E> своєю чергою має перші дві властивості: додатково до звичайного масиву він ще перевіряє, що довжина масиву дорівнює довжині enum E (або U, як ми показали, від E до U дуже простий перехід). Проте EnumArray<E> не перевіряє, що кожна конституента входить один раз.
- Тоді можна казати, що звичайні масиви TypeScript — це вичерпний масив 1 порядку (адже задовольняє тільки першу властивість). EnumArray<U> — вичерпний масив 2 порядку (адже задовольняє і першу і другу властивість). За цією логікою, ExhaustiveArray<U> був би вичерпним масивом 3 порядку. До речі, тип any[] можна вважати вичерпним масивом 0 порядку, адже він не задовольняє жодної властивості (окрім факту буття масивом) вичерпного масиву.
- Exhaustive-масиви потрібні тоді, коли ми маємо певний enum або union, на базі якого необхідно створити type-safe масив. Часто така потреба виникає, коли необхідно задати порядок елементів enum або union, адже за своєю природою ці типи є невпорядкованими.
Найкращі коментарі пропустити