Вичерпні масиви в Typescript та їхня роль для розробників

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

Всім привіт! Мене звати Владислав, я 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[];

TS Playground link

Уявімо, що нам потрібно додати нову мову до проєкту. Для цього ми підемо редагувати enum Language і додамо в нього код нової мови. Проблема полягає в тому, що у разі здійснення такої операції у компоненті LanguageSelect також необхідно відредагувати ці дві сутності. Проте повідомлення про помилку, що нової мови не вистачає, згенерує лише languageDisplayNameMap, а масив languageOptions не покаже жодної помилки. Це логічно, адже тип Language[] просто вказує, що це довільний масив з елементів enum Language.

Отже, масив languageOptions не є type-safe, і ми маємо памʼятати про те, що у разі змін, необхідно не забути й про цей масив.

Що конкретно ми хочемо від languageOptions:

  1. Щоб усі його елементи були з Language.
  2. Щоби довжина цього масиву дорівнювала довжині (кількості конституент) 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;

TS Playground link

Це означає, що масив можна затипізувати як 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>;

TS Playground link

Далі я додав ще літерал 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;

TS playground link

Таким чином, якщо ми знайдемо спосіб динамічно будувати цей 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>>

TS Playground link

Дисклеймер:

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

TS Playground link

Вийшло! У такий спосіб ми декомпозували попередній union від 0 до N-1 на 2 типи CountTo<ObjectLength<E>>, і створили перший з них:

type EnumArray<E> = Record<CountTo<ObjectLength<E>>, ObjectValues<E>>

TS Playground link

Крок 3. Довжина об’єкта

Наступний крок — створюємо тип ObjectLength, який повертатиме довжину об’єкта, тобто кількість його ключів. Далі ми не будемо працювати саме з початковим обʼєктом, бо для вирахування довжини нам потрібен лише union ключів. Напишемо тип UnionLength<U>, а ObjectLength можна виразити через UnionLength та оператор keyof.

Як ми зрозуміли з попереднього кроку, числова константа (літерал) на рівні типів є лише у кортежів у якості довжини. Тобто для її отримання необхідно перетворити цей union в кортеж і взяти його довжину:

type UnionLength<U> = Tuple<U>['length'];

TS Playground link

Отже, ми зрозуміли, що надалі можемо працювати не з початковим обʼєктом, а лише з 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 Playground link

Сама функція — доволі проста і, на перший погляд, у ній немає нічого цікавого. Але що буде, якщо запросити в 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;

TS Playground link

Ми отримали обʼєкт з певними властивостями. У документації 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);

TS Playground link

Так і є! Вони однакові, бо привласнюються один одному. Якщо ви спробуєте викликати 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!!!

TS Playground link

Отже, на цьому етапі ми дізналися, що 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;

TS Playground link

Далі ми намагаємося отримати тип аргументів перевантаженої функції за допомогою простого умовного виразу та оператора 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';

TS Playground link

Щоби це зробити, необхідно виконати прості перетворення:

  1. Початковий union перетворити на обʼєднання функцій (далі псевдокод):

(Z) => 0 | (X) => 0 | (S) => 0.

  1. Перетворити цей union на Intersection, тобто створити перевантажену функцію: (Z) => 0 & (X) => 0 & (S) => 0.
  2. Зробити інференс типу його аргументу за допомогою оператора 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';

TS Playground link

Отже, на цьому кроці ми створили тип 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]

TS Playground link

Таким чином ми задекларували тип 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

TS Playground link

У нас декларується клас 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

TS Playground link

Це і називається коваріантність: якщо 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

TS Playground link

Задекларуємо тип 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

TS Playground link

Тут 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;

TS Playground link

Отже, що ми зрозуміли про варіантність:

  1. Covariance: () => Cat привласнюється () => Animal, бо те, що вертає тварину — може вертати і кішку, проте не навпаки: не всяка тварина є кішкою. Коваріантність зберігає «природний» порядок успадкування. У такому випадку ми кажемо, що Cat і Animal стоять в «коваріантній позиції».
  2. Contravariance: (Animal) => void привласнюється (Cat) => void, бо те, що очікує Animal, може прийняти і Cat, але не навпаки: те, що очікує кішку, не може працювати з довільною твариною. Контрваріантність інвертує «природній» порядок успадкування. Тут ми кажемо, що Cat і Animal стоять в контрваріантній позиції.
  3. Invariance: (Animal) => Animal не привласнюється (Cat) => Cat, бо не усі повернуті Animal є кішками, та (Cat) => Cat не привласнюється (Animal) => Animal, бо те, що очікує кішку, не може прийняти довільну тварину. Тут ми кажемо, що Cat та Animal стоять в інваріантній позиції.
  4. 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

TS Playground link

  • На тесті № 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 🗿🗿🗿

TS Playground link

  • Тест № 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?

TS Playground link

Тип 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

TS Playground link

Давайте трохи модифікуємо цей приклад: тепер 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?

TS Playground link

Ми не можемо передати просто обʼєкт, який має лише 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'}

TS Playground link

Вийшло! Тепер у нас є всі інструменти для завершення типу 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"]

TS Playground link

Повне рішення задачі:

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']

TS Playground link

Уважні читачі могли помітити, що тут EnumArray трохи розширено: до нього додано Array<ObjectValues<E>> та {length: UnionLength<keyof E>}. Ці сигнатури були додані до типу, щоби він повністю задовольнив інтерфейс ArrayLike, та на ньому були доступні всі методи масиву.

Отже, ми:

  • Створили тип Intersection<U> який перетворює union в Intersection.
  • Завершили тип Tuple<U>, який перетворює union в Tuple (кортеж).
  • Завершили тип EnumArray<E>, який перетворює обʼєкт E в кортеж, елементами якого можуть бути тільки значення E, а довжина масиву має дорівнювати кількості ключів E.

Рішення:

  1. Представили Array<T> як Record<number, T> & {length: number} & Array<T>.
  2. Представили number в цьому типі як CountTo<UnionLength<T>>.
  3. UnionLength<T> виразили як довжину кортежу Tuple<T>[‘length’].
  4. Виразили 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 входить до масиву один раз.

Отже, з цього визначення виходить, що:

  1. Звичайні масиви в TypeScript мають лише першу властивість вичерпних масивів: вони просто перевіряють, що усі елементи того типу, який треба, але нічого більше.
  2. EnumArray<E> своєю чергою має перші дві властивості: додатково до звичайного масиву він ще перевіряє, що довжина масиву дорівнює довжині enum E (або U, як ми показали, від E до U дуже простий перехід). Проте EnumArray<E> не перевіряє, що кожна конституента входить один раз.
  3. Тоді можна казати, що звичайні масиви TypeScript — це вичерпний масив 1 порядку (адже задовольняє тільки першу властивість). EnumArray<U> — вичерпний масив 2 порядку (адже задовольняє і першу і другу властивість). За цією логікою, ExhaustiveArray<U> був би вичерпним масивом 3 порядку. До речі, тип any[] можна вважати вичерпним масивом 0 порядку, адже він не задовольняє жодної властивості (окрім факту буття масивом) вичерпного масиву.
  4. Exhaustive-масиви потрібні тоді, коли ми маємо певний enum або union, на базі якого необхідно створити type-safe масив. Часто така потреба виникає, коли необхідно задати порядок елементів enum або union, адже за своєю природою ці типи є невпорядкованими.
👍ПодобаєтьсяСподобалось18
До обраногоВ обраному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

Перш за все хочу подякувати за таку працю, та чудове пояснення механіки роботи з типами. Цю статтю можна використовувати навіть просто як навчальний, або справочний матеріал без прив’язки до задачі. Круто.

А тепер трошечки подушню. Скоріш за все ви взяли задачу для того щоб показати чудову гімнастику з типами, але чи вартє виведення типів у массиві всих тих рядків тайпскриптового коду, який через пару тижнів неможливо буде зрозуміти? Звісно, якщо писати бібліотеку, то можливо воно й знадобиться, але в прикладному коді навряд.

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

Дякую за статтю! Про такі речі цікаво читати 🙌

Погоджуюсь з думкою, що щоб затягнути на проект такі типи потрібна дуже вагома аргументація, оскільки складність підвищується і потрібно розуміти ціну подальшої підтримки у конкретному контексті. З іншої сторони копати в систему типів, яку надає TS — це класна історія, яка має багато бенефітів.

Читаючи коментарі під постом, хочеться висловити вам підтримку, оскільки екологічна дискусія там не проглядається 😔

Не саме оптимізоване рішення (і не особливо корисне), але певною мірою тип для вичерпних масивів вдалося створити.

// Type to exclude a specific type from an array
type ExcludeFromArray<T extends any[], U, Acc extends any[] = []> = T extends [infer First, ...infer Rest]
  ? First extends U
    ? [...Acc, ...Rest]
    : ExcludeFromArray<Rest, U, [...Acc, First]>
  : T;
  
// Type to generate all permutations of an array
type GeneratePermutations<T extends any[], Acc extends any[] = []> = T extends [infer First, ...infer Rest]
  ? {
      [Index in keyof T]: GeneratePermutations<ExcludeFromArray<T, T[Index]>, [...Acc, T[Index]]>;
    }[number]
  : Acc;

type Writable<T> = { -readonly [P in keyof T]: T[P] };

// Test data: array of numbers as const
const numberOptions = [1, 2, 3, 4] as const;

// Type representing permutations of the numberOptions array
type NumberPermutations = GeneratePermutations<Writable<typeof numberOptions>>;

// Valid permutation examples
const validPerm1: NumberPermutations = [1, 2, 3, 4]; // valid
const validPerm2: NumberPermutations = [4, 3, 2, 1]; // valid
const validPerm2: NumberPermutations = [3, 1, 4, 2]; // valid

// Invalid permutation examples
const invalidPerm1: NumberPermutations = [1, 3, 2, 4, 5]; // invalid (too many elements)
const invalidPerm2: NumberPermutations = [1, 2, 4, 2]; // invalid (duplicate elements)
const invalidPerm3: NumberPermutations = [1, 2, 3]; // invalid (too few elements)

Дякую за цей семпл коду! Безцінний вклад в розкриття теми. Пункт про те, що вичерпний масив 3-го порядку не існує можна забирати))

Трохи дописав, щоб працював з union-ами, використовуючи тип Tuple:

type LanguageOption = "en" | "fr" | "us" | "uk";
type LanguageOptions = Tuple<LanguageOption>;

type ExhaustiveArray<U> = GeneratePermutations<Writable<Tuple<U>>>;

const test: Array<LanguageOption> = ["en", "fr", "uk", "us"] satisfies ExhaustiveArray<LanguageOption>;
[Playground link][www.typescriptlang.org/...​YUoFt 3nCwJ3w-wSOzYtq2gA

Можливо теж не найестетичніше рішення, але все ж таки за допомогою типів, можна отримувати підказки від тайп скрипт про невідповідність значень у масиві з енамом.

enum Language {
  EN = 'en',
  ES = 'es',
  FR = 'fr',
  DE = 'de',
  PT = 'pt',
  JA = 'ja',
  TR = 'tr',
  IT = 'it',
}

const languageOptions = [
  Language.EN,
  Language.FR,
  Language.ES,
  Language.DE,
  Language.PT,
  Language.JA,
  Language.TR,
  Language.EN,
] as const satisfies Record<number, Language>;

type LanguageOption = (typeof languageOptions)[number];
type IsEqual = LanguageOption extends Language ? (Language extends LanguageOption ? 1 : 0) : 0;
const correctArrayType: IsEqual = 1; // invalid type (duplicate elements "en")

Дякую за цікаву статтю! Але з власного досвіду — пишу на TS ще з часів Angular 2, коли він був на RC1 — чим простіший код, тим легше його підтримувати. Амінь :)

Цікава стаття. Дещо нове для себе відкрив, дещо пригадав. Але треба визнати, що EnumArray, який отримали в кінці, вийшов максимально дивним. Тяжко зрозуміти його практичну цінність, якщо він дозволяє заповнити array одним і тим же елементом, головне щоб кількість відповідала кількості елементів enum-у.
Але оминаючи це, дякую за контраваріантність і коваріантність.

Дякую за відгук!

Особисто я в якості головної цінності цієї статті бачу наступне:
1. Ця стаття дуже гармонійно та природньо поєднує між собою зовсім різні та складні/просунуті TS концепції
2. Дуже добре демонструє глибину та комплексність мови TS, що має, на мою думку, великий популяризаторський ефект технології
3. Ця стаття дуже добре демонструє роботу принципу «що іноді щоб вирішити дуже просту (на рівні формулювання) задачу необхідно зануритись та дійти до занадто абстрактних абстракцій» — що правда про Тайпскрипт

Питання про практичну цінність залишається відкритим, в статті не має заклику негайно пушити цей код собі в проєкти. 100% можу сказати, що створити повноцінний ExhaustiveArray неможливо в загальному випадку. Як альтернативу можна розглядати покриття тестами на рівні типів або навіть поєднати ці 2 підходи

Ласкаво просимо в шаблони С++ та їхнього пророка — Boost. Це красиво, але краще не треба, якщо у вас немає нескінченої кількості вільного часу це підтримувати...

Зазвичай, використання такого складного функціоналу TS — ознака косяків в архітектурі рішення. Хардкодити датасети на фронті... а потім бігати по модулям і руками то всьо править.. Meh..

У вас тут отклеилось:

const languageOptions = [
 Language.EN,
 Language.FR,
 Language.ES,
 Language.PT,
 Language.PT,
 Language.JA,
 Language.TR,
] as const satisfies Record<0 | 1 | 2 | 3 | 4 | 5 | 6, Language>;

Компилируется на www.typescriptlang.org/play, при том что все еще присутствует проблема, которую вы пытались решить.

не зрозумів проблеми: цей фрагмент і має компілюватись

www.typescriptlang.org/...​C5oWmzltWuDigQ6gkN4a77SAA

Проблема в том, что в моем примере Language.PT повторяется, а Language.DE отсутствует. Это ок? В чем тогда смысл 20-страничного талмуда, если в итоге массив может быть заполнен мусором и это никак не подхватывается компилятором?

В статті прямо сказано, що до EnumArray немає вимоги до унікальності і пояснено чому

І це жодним чином не ставіть цінність самої статті під питання, адже стаття має за мету створити ExhaustiveArray 2-го порядку і вона це робить) Навпаки завдяки цій статті ми можемо ставити тепер питання: «а яка практична цінність цього типу, якщо *аргументи*», але це не впливає на цінність статті)

В статті прямо сказано

Сильно много букоф, я все не осилил прочитать, только часть кода.

Чого тільки не придумають, аби на нормальних мовах не писати

А ви назвіть «нормальну мову», з можливостями TypeScript по створенню типів.

Та хоча б з структурною типізацією назвіть :)

Вот пример как то, что ТС пытался сделать вверху, делается в языке здорового человека

users.rust-lang.org/...​st-of-enum-variants/99891

Ви б так і почали з того, що ви просто не любите TS і заощадили кучу б часу мені)

Якщо я правильно зрозумів завдання таке:

Що конкретно ми хочемо від languageOptions:

Щоб усі його елементи були з Language.
Щоби довжина цього масиву дорівнювала довжині (кількості конституент) enum Language.

Пане Владислав, скажіть, а чим викликано, що простіші варіанти вам не підійшли?

Варіант 1 (зі збереженням enum):

enum Language {
  EN = 'en',
  ES = 'es',
  FR = 'fr',
  DE = 'de',
  PT = 'pt',
  JA = 'ja',
  TR = 'tr',
}

const languageOptions: Language[] = Object.values(Language);

Варіант 2 (із заміною enum):

// todo rename to languages
const languageOptions = ['en', 'es', 'fr', 'de', 'pt', 'ja', 'tr'] as const;
type Language = (typeof languageOptions)[number];

const languageDisplayNameMap: Record<Language, string> = {
  en: 'English',
  es: 'Spanish',
  fr: 'French',
  de: 'German',
  pt: 'Portuguese',
  ja: 'Japanese',
  tr: 'Turkish',
};

тим, що ми втрачаємо контроль за можливістю вказувати порядок мов) Навіть у другому варіанті, далі покажу чому

В першому варіанті здається це очевидно: бо `Object.values` не гарантує ніякого порядку. Також (це вже не стосується теми статті та зовсім з іншого питання), особисто я не за те, щоб на TS enum-ах, викликати Object.values або виконувати над ними інші схожі маніпуляції через саму природу TS enum-ів з купою pitfall-ів та нюансів, про які треба памʼятати, а все, що можна забути — це все не type safe

В другому варіанті здається все ідеально: тепер у нас source of truth не обʼєкт, а масив з фіксованим порядком. Але це не працює так, бо коли я захочу в 3-х різних компонентах мати різний порядок мов, то мені для кожного потрібно буде створити свій масив з порядком, який нема більше чим типізувати.
Якщо уважно подивитись в статтю, то можна побачити, що enum Language оголошено в окремому файлі від `languageDisplayNameMap` та `languageOptions`, бо в `languageOptions` вказаний порядок саме для конкретного компоненту `` (і він і оголошений поруч з компонентом), а в компоненті `` в мене може бути інший порядок

Ви праві в тому, що можна сказати «а в мене буде тільки один селект з одним порядком на весь проєкт» і тоді дійсно другий варіант вирішить проблемо. Але потрібно памʼятати, що це лише окремий випадок, а в загальному випадку це вже не спрацює

тим, що ми втрачаємо контроль за можливістю вказувати порядок мов

Про це в постановці завдання я не побачив. Що заважає порядок визначити через порядок ключів об’єкта languageDisplayNameMap? Лейбли будуть для кожного із селекту свої, адже так?

в загальному випадку це вже не спрацює

Для загального випадку ваше рішення не підходить, оскільки не перевіряє на дублікати.

Заважає, що порядок ключів обʼєкта не гарантується при декларації згідно до специфікації і тим паче не гарантується при обході ключів обʼєкту. Той факт, що існують міжбраузерні домовленості про те, як браузери де-факто реалізовують порядок ключів, не є достатнім аргументом покладатись на ці домовленності через низку причин:
1. по-перше це не фіча, а те, що існує з історичних причин і не прибрано з міркувань зворонтої сумісності
2. по-друге ці «домовленності» є forgettable, а все що можна забути — це все не type safe
3. по-третє це просто анти-патерн, бо з коду інша людина та навіть сам автор ніколи не зрозуміє/не згадає з часом «так, а це я впорядковано ключі вказав чи я можу змінювати порядок», якщо не вказати коментар, що також псує якість коду. Будь-який розробник має знати, що якщо він змінить порядок будь-якої властивості в обʼєкті, то код його не зламається і тести не почнуть ломатись
4. по-четверте ніде нема гарантій, що певний браузер/певне середовище виконання буде дотримуватись цих домовленостей, і що вас код не зламається через це.
Обʼєкти в JS невпорядковані по своїй природі та згідно до специфікації, відповідно їх не можна використовувати для опису впорядкованих сутностей.

Об’єкти в JS впорядковані згідно специфікації починаючи з ES2015

Дякую за інформацію.

В будь-якому разі це не скасовує валідність першого та третього пунктів: 1 — це не фіча. 3 — це просто анти-паттерн та не зручно

Це як раз таки «фіча». Не існувало причин фіксувати цей порядок, але його зафіксували. Тому фіча

Щодо 3 сперечатися не буду, бо це протирічить як раз тому що це «фіча» :)

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

Стосовно другого поїнту, що рішення в статті не підходіть, оскільки не перевіряє на дублікати -
в статті у висновках покрите це питання: в статті розглядається розробка так званого вичерпного масиву «2-го» порядку, і окремо сказано, що створити повноцінний загальний тип вичерпного масиву «3-го» порядку, який ще і на унікальність перевіряє, неможливо в загальному випадку. Можна лише покрити окремими тестами на унікальність які вертатимуть `false` якщо є дублікати, проте інтегрувати цю перевірку в тип в загальному випадку без факторіальної складності алгоритму ще не вдалось нікому)

Дуже гарна стаття. Навряд щось таке поїде в продакшен з моїм комітом. Але концепція програмування через типи та реальний кейс застосування мене дуже зацікавила. Дякую.

Вау, давно не читав таких цікавих статей!

Молодець, так тримати 👍

Треба відправляти т800 в 2012й, бо скоро треба буде переносити прорахування типів в хмару :)

Подивимося на деякі деталі реалізації цього дропдауну: у коді визначено enum Language з переліком мов у проєкті.

Щось я пропустив той момент, коли антипатерн став нормою... Визначення переліку мов в коді? Шо сі робе...

Замінить перелік мов в коді на перелік пород кошенят) Тематика контенту в enum-у не впливає на зміст статті та на тему статті в цілому

Нічого не зміниться. Тримати ENUM в коді — це явний постріл собі в ногу. Тема статті для мене звучить так, я неправильно використовую технології, мені треба вирішити взагалі сторонню проблему, тому я напишу рішення щоб IDE мені підказки давало.

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

до кінця не зрозуміло, що саме є обʼєктом критики: те, що це саме нативний тайпскриптовий enum, чи сам факт використання якогось обʼєкту за своєю семантикою виконуючого функції enum-у

якщо перший поїнт, то я можу сказати наступне: замінить enum на const obj = {...} as const — валідність сказаного в статті не зміниться. Більше того, ви можете взагалі відмовитись від рантайм сутностей і тримати все на тайп левелі в union-і: type Language = ... | ... | ...; по аналогії з EnumArray можна дуже легко створити UnionArray, який працює так само, лише відрізняється спосіб опису вхідних даних: в EnumArray це літерал обʼєкту, а в UnionArray літерал union-у

якщо другий поїнт, то це щось цікаве) Я ніколи не чув, що enum-и та їх аналоги як концепція це антипатерн) Розкажіть будь-ласка про альтернативи тоді, і як за допомогою цих альтернатив мені створити union з назвами пород поняшек:
type PonyBreed = 'Pegassus' | 'Unicorn' | 'Alicorn' | 'Megacorn' | 'Ponycorn'<code>

Ви неправильно зрозуміли мої слова. Антипатерн не ENUM, а його декларація в коді. Оті ваші поняшки і є антипатерном. Перелічення відноситься до даних, а не до функціональності. З точки зору логіки роботи з даними, ваша функціональність мусить бути максимально універсальною та абстрактною. Якщо ваш код реагує на значення ENUM, то ви створюєте локальне місце потенційного збою.

Наприклад, у вас є LTR та RTL мови з переліку мов, які підтримує ваша система. Розробник вирішив зробити просте правило, мовляв, ящо в мене якісь арабські мови, то прийняти напрямок письма RTL, інакше LTR. Тупо if..else. Це просте рішення, але з великими поганими наслідками в майбутньому. Будь-які зміни в ENUM будуть вимагати обовʼязкового перегляду всіх місць, де використовуються його значення. Набагато ефективнішим рішенням буде наступне. Йдемо від зворотнього, від вимог підтримувати більше ніж один напрямок письма. Системі для коректної роботи не потрібен перелік мов, а потрібне конкретне значення напрямку. Як його отримати? З мапінгу. А наявність такого мапінгу робить ENUM для мов та напрямків письма безглуздими, тому що він і є даними.

Якщо є мапінг, то його вже треба зберігати в конфігурації, а не коді. Але то вже інша історія.

«Його декларація в коді» — це і значить що сам enum є аніпатерн, бо що ще можна робити з enum-ами, окрім як їх декларувати? Хіба що дивитись на них в чужому коді)

І я так і не зрозумів — як мені визначити напрямок письма *не* знаючи мови? Це ж проста чиста функція: мова => напрямок письма?

Ще раз, код мусить містити мінімум даних. Дані мусять лежати де-інде в іншому місці. Якщо ви декларуєте ENUM в коді, то це є антипатерном. Він мусить прийти з конфігурацією або з даними ззовні, але ніколи не описуватися в коді. Вся ваша функціональність мусить бути абстрактною та не спиратися на значення того ENUM.

Про напрямок. Спробуйте побудувати цей приклад в коді. Зрозумієте.

Ви не відповілі на запитання:

є задача: визначити порядок письма в програмі

моє рішення: рішенням цієї задачі є чиста функція (lang: Language) => RTL | LTR

ви кажете, що «Language» писати не можна. Відповідно яке ваше рішення?
ви знаєте таку функцію: () => RTL | LTR? чи я маю написати (lang: string) => ...?

Називати «будь-яку декларацію enum-у» в коді (а відповідно і enum-и як концпецію) антипатерн — це принаймні дуже екстравагантне твердження, а на ділі — неправдиве

Правда в тому, що enum-и не завжди потрібні і не завжди використовуються за призначенням, але казати що enum як концепція це антипатерн — це не правда

Ви не відповілі на запитання:

Я хотів, щоб ви трохи подумали над задачею, перед тим, як мені давати відповідь.

моє рішення: рішенням цієї задачі є чиста функція (lang: Language) => RTL | LTR

Це саме безглузде рішення. Ви знання про мапінг переносите в код. Якщо відмовитися від ENUM та перейти до іншої структури даних, таку як

{
  "languages": [
    { "id": "en", "title": "English", "direction": "ltr" },
    { "id": "fa", "title": "Farsi", "direction": "rtl" }
  ]
}
то ваша функція була б звичайним геттером.
це принаймні дуже екстравагантне твердження, а на ділі — неправдиве

Ок, як скаже пан. Років через 10 можемо з цього приводу поспілкуватися ще раз.

Прикольний підхід, мені подобається,

але все одно ж у вас direction буде типізовано як ltr | rtl
і знову ж таки id мов ви би також типізували як union допустимих значень — мапінг нікуди не дівається де-факто

при цьому знову ж таки, мені в різних UI можуть бути потрібні різні варіанти тайтлов, і більш того — інший порядок мов, то мені все одно доведеться використовувати мапінг

Мапінг — це лише окремий випадок чистої функції. Я не вважаю справедливим та коректним вважати цілу підмножину функцій «безглуздим рішенням» та «антіпатерном»

але все одно ж у вас direction буде типізовано як ltr | rtl

Ні, він буде будь-яким, ніхто не обмежує мати тільки два, їх в світі набагато більше. Тому просто приймайте ці данні як є, як строку, а не як ENUM. Якщо ви зробите перелічення, то ви стріляєте собі в ногу.

при цьому знову ж таки, мені в різних UI можуть бути потрібні різні варіанти тайтлов, і більш того — інший порядок мов, то мені все одно доведеться використовувати мапінг

Порядок ви можете влаштувати який завгодно, використовуючі map-reduce. Якщо треба інші тайтли — перетворіть на масив ключ «title» та всі варіанти зберігайте там. Дефолтний індекс [0].

Не намагайтеся тягнути зайві дані в код. Не треба воно там. Тому це й антипатерн.

Мапінг — це лише окремий випадок чистої функції.

Мапінг як функція чи як структура даних? Тому що структура даних, яка є допоміжною або результатом роботи функції, не може бути чистою функцією.

Я не вважаю справедливим та коректним вважати цілу підмножину функцій «безглуздим рішенням» та «антіпатерном»

Знову невірне розуміння. Антипатерном є декларація ENUM в коді.

приймайте ці данні як є, як строку, а не як ENUM

але мій код вміє працювати з конкретними значеннями direction, а не з невідомими строками

Яка взагалі різниця, яке значення має ключ direction? Вважайте його таким, що надсилається сторонньою системою, тобто ви не контролюєте взагалі ці дані, це транзит в більшості випадків для вашого коду. У вас знання про те, як реагувати на значення ключа напрямку письма, мусить бути ЛИШЕ у системи рендерінгу, а не в API чи десь по ходу в коді.

дійсно яка різниця. Пропоную все типізувати завжди як unknown і ставити 150000 type guard-ів та асерторів, адже взагалі будь що може бути будь чимось

Ви намагаєтеся бездумно все типізувати, навіть те, що типізувати не треба.

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

Антипатерн не ENUM, а його декларація в коді.

теж досить сумнівне, бо чого б тоді той енум взагалі придумували.

а рендер шо, не частина апки?

А ви текст власноруч на екрані розташовуєте, чи просто використовуєте готову бібліотеку, яка вже вміє в різні напрямки тексту?

чи він сам має верифікувати дані, котрими його годує бекенд?

Так, бібліотека має певні параметри, які треба їй передати, щоб вона правильно розташувала текст на екрані. Бібліотека приймає тільки те, що може зрозуміти, тому так, саме вона буде займатися тим, чи правильні параметри ви передали. Навіщо ви ускладнюєте постійно все?

бо чого б тоді той енум взагалі придумували

Придумували. Тоді, коли про мікросервісні архітектури, веб-розробку та багато іншого не знали. Світ змінився, змінюються й вподобання. Колись й GOTO було норм. А зараз це антипатерн.

А ви текст власноруч на екрані розташовуєте, ч

я можу бути взагалі посередником, котрий отримує дані з сервісу А і надає їх для інших сервісів. І дуже хотілося б, щоб все це було консистентно.
Вреті не тільки direction єдиним. Наприклад всякі араби не хочуть бачити ізраїль в переліку країн. Тобто вже одне це примусить тримати десь список країн з особливостями обробки даних.

Тоді, коли про мікросервісні архітектури, веб-розробку та багато іншого не знали.

пехопе нормальні енуми тільки осьосьо завели.

я можу бути взагалі посередником, котрий отримує дані з сервісу А і надає їх для інших сервісів.

Тоді вам категорично не рекомендується втручатися в дані. Проксі мусять бути максимально прозорими. Інакше сторонній сервіс міняє специфікацію (додає ще одне значення в ENUM, щоб не лякала узагальнена фраза) та вся рахітектура лягає миттєво.

Наприклад всякі араби не хочуть бачити ізраїль в переліку країн. Тобто вже одне це примусить тримати десь список країн з особливостями обробки даних.

Це лише додаткові пермішки для того масиву, що я наводив вище. На кшталт

{ "id": "fa", "title": "Farsi", "direction": "rtl", "blacklist": ["il"] }
Це сильно спрощено. Там треба трохи іншу структуру мати, треба виносити країну ключем, мови — в масив в дані ключа.

Давайте трохи з іншого боку подивимося. Ви змінюєте умови задачі, ваша структура, в якій ви зробите всю конфігурацію, мусить відповідати вимогам задачі. Але, функції все одне мусять бути максимально абстрактними, по типу map/reduce. Треба перелік унікальних мов — map. Треба з урахуванням локальних особливостей — reduce.

пехопе нормальні енуми тільки осьосьо завели.

Ну, треба якісь «покращення» робити...

після «перетворить на масив ключ title» можна більше ваші слова про антипатерни в серйоз не сприймати, адже більшого пострілу в ногу, про які ви так любите казати — важко вигадати

Мапінг як чиста функція* Більш того обʼєкт мапінгу також можна вважати чистою функцію — згідно до теорії множин це повноцінне функціональне відношення

можна більше ваші слова про антипатерни в серйоз не сприймати

Можете сприймати мої слова як завгодно, я не проти, це ваше право. Немає ніякого пострілу в ногу, взагалі. Вам персонально не подобається таке рішення, але не значить, що воно погане. Дотримуйтесь принципів KISS.

Красива iнтeлeктуальна гiмнастика.

А багато людeй цe зможуть зрозумiти та пiдтримувати бeз посилання на таку статтю в кодi?

Розробка цe про управлiння (навiть приборкання) зростаючої складностi, а нe додавання дe вона нeпотрiбна. Нi, звiсно, можe всi так на typescript i пишуть, тодi норм. Головнe нe забувати бородатий жарт:

«Пишите код так, как будто сопровождать его будет склонный к насилию психопат, который знает, где вы живёте».

А багато людeй цe зможуть зрозумiти та пiдтримувати бeз посилання на таку статтю в кодi?

Та так пишуть тільки перший тиждень, поки продакт таски нарізає, а потім ти будеш писати any скрізь де потрібно і не потрібно, бо робота повинна була бути зроблена ще вчора.

:)
Ну може ні any, але і не така робота з типами.
Бо коли рідко використовуєш подібне і бачиш, то думаешь — і що це, і нахіба. Спочатку думка що не той файл відкрив, і хтось haskell в проект закомітив.

Так пишуть ті, хто розробляє якесь API або бібліотеки, тобто код, кінцевим споживачем якого є розробники, а не продакти, бо тайпскрипт це в також про DX. В повсякденних проєктах, де кінцеві споживачі це люди, дійсно просто важко знайти застосування для складних типоманіпуляцій та типовиведень, але в складних проєктах все одно можна)

Зі свого досвіду роботи та досвіду спілкування з TS розробниками можу зробити наступні тези:

Ці типи дійсно мʼяко кажучи складніше, ніж наші повсякденні типи, але це тільки на перший погляд. По-перше усі типи тут, окрім самого EnumArray існували та активно використовувались і до цієї статті, особливо тип Intersection (в різних проєктах може називатись по-різному: Intersection, UnionToIntersection, IntersectionFrom, U2I — навіть таку назву зустрічав)

По-друге, також справедливо зауважити, що такі більш «просунуті» типи рідко зустрічаються саме в повсякденній розробці, бо в більшості нас наша повсякденна розробка спрямована на створення коду, кінцевими споживачами якого будуть звичайні користувачі; проте коли наші кінцеві споживачі — це розробники (тобто коли ми пишемо власну лібу або створюємо якесь ± не саме тривіальне API), то одразу зʼявляється потреба в якихось більш складних інструментах для здійснення нетривіальних маніпуляцій та трансформацій з типами.

В принципі це проблема Typescript — що щоб створити щось дійсно прикольно потрібно писати напів-езотеричний код та добряче так зануритись в теорію множин та функціональне програмування, але в цьому є і фіча: цей езотеричний код у всіх однаковий і дуже розповсюджений, і коли один раз його розумієш — далі вже можеш його закритими очима створювати. Короче кажучи — ті хто один раз це побачив, запамʼятовують це назовні)

Конкретно в цій статті немає призову негайно це затягувати в свої кодові бази і ви правильно сказали, що це красива інтелектуальна гімнастика) Мета цієї статті — 1) показати, що тайпскрипт це глибше ніж просто `as any` тa `props: any`; 2) показати, що дуже «складні» на перший погляд речі випливають з дуже простої на перший погляд проблеми

В принципі це проблема Typescript — що щоб створити щось дійсно прикольно потрібно писати напів-езотеричний код та добряче так зануритись в теорію множин та функціональне програмування, але в цьому є і фіча

В ТайпСкрипт тип змінної чи виразу визначається не скільки їх значенням чи формою (структурою), як декларацією самого типу. В деяких випадках це робить визначення типів не дуже акуратним («sound»). Тому, хоча ТС і має юніон- і інтерсекшн-подібні операції над типами, ці типи не множини самі по собі. Тому (наївну) теорію множин скоріше можна пришити тільки вбудованій DS Set.

Те ж саме можна сказати і про функціональну складову мови. З часів JS в TS концептуально нічого не змінилось в плані функціонального програмування. Малого того, на мою думку, ТС тільки укріпив свої позиції, як ООП мови з нормальними класами (не тим прототипним жахіттям, що до цих пір є в жс), інтерфейсами, дженериками і тд.

Ну, а про «езотеричне», то таке враження, що мова останнім часом тільки в цьому напрямку і рухається.

ООП мови з нормальними класами (не тим прототипним жахіттям, що до цих пір є в жс)

Смішно. TS транспайлиться в JS, тому немає в ТС нормальних класів, є тільки синтаксичний цукор навколо прототипів.

Так ніде нема «нормальних класів»- все трансплайтиться в asm, є тільки синтаксичний цукор навколо вказівника і memalloc.

та то просто мантра така — як щось об’явити синтаксичним цукром, то це буде убойний аргумент :D

а те що навіть while та for то синтаксичний цукор навколо if (...) ... goto BREAK; else ... goto CONTINUE; на думку глибоким знавцям що такє «нормальні класи» не спадає.

Ви можете мені назвати хоч одну TSVM (TypeScript Virtual Machine)?

Дякую, подивлюсь уважніше пізніше

Ті VM, що є в переліку, побудовані над v8 або nodejs.
Такі як dino, транспалять TS в JS.
Компіляцію в LLVM можна назвати TSVM?

Це синтаксичний цукор над JNE та JMP, а «глибокі знавці» нехай дальше убиваються о свої мантри.

Самі JNE та JMP вже є синтаксичним цукром ;) CPU не розуміє цих команд, він знає тільки 75 та E9 в бінарному вигляді... (там все трохи складніше, але то вже не важливо)

В ASM? Може в машинні коди швидше? Бо ASM процесор не розуміє...

Ну так і типи з TS не попадають в JS взагалі ні в якому виді. Можна весь день на корчити з себе Хаскель-ванабі, а в JS попаде рівно 0 із написаного.

Цікаво, і як же ж воно бідолашне працює... І куди воно попадає...

Ніде не написав, що типи в тс є множинами) Написав, що для розуміння TS необхідні принаймні базові знання теорії множин. Те ж саме і про функціональне програмування: ніде не сказав, що TS додав нові інструменти для функціонального програмування в JS. Сказав, що для глибшого розуміння TS знання теорії функціонального програмування є обовʼязковим). Хоча навіть тезис про те, що TS нічого нового для ФП не приніс це також дискусійно, адже можливості тайпскрипта та його переваги розкриваються на повну саме тоді, коли пишеш код в функціональному стилі, відповідно можна справедливо казати, що ТS мотивує та заохочує розробника писати саме в такому стилі

Ви написали, що потрібно

зануритись в теорію множин

Я відповідаю, що для TS не потрібна навіть наївна теорія множин, бо type inference в TS не працює з типами, як з множинами.

для глибшого розуміння TS знання теорії функціонального програмування є обовʼязковим

Як знання таких абсолютно базових концепцій як purity та totality функції, а також іммутабельності, дає «глибше розуміння TS»? І чому це обов’язково?

бо type inference в TS не працює з типами, як з множинам.

І це трохи засмучує, особливо коли ти повертаєшся до світу JS з xQuery, де множина є нормою відповіді функції...

Давайте так: теорія множин для будь-якого розуміння TS не потрібна якщо і тільки «якщо представляти типи як множини, то жодне з положень теорії множин не можна буде застосувати до цих множин»; відповідно, «якщо представити типи як множини, і є хоча б одно положення теорії множин яке можна буде застосувати до цих множин, то теорія множин є потрібною для розуміння TS». Забавно, що щоб скласти ці 2 твердження, також потрібно розуміння кванторів з теорії множин)

Якщо ставитись до типів як до множин, то можна побачити, що багато положень застосовані і до типів:
1. Концепція union-типів
2. Концепція intersection-типів
3. Концепція привласнень на поняттях під множина та супер множина
4. Концепція never як пустої множини
5. unknown як універсум
Можна побачити і місця, де типи проявляють себе складніше або відмінно від множин: наприклад string & number це never, хоча строго кажучи, і там і там є метод toString, тому строго до теорії множин логічно очікувати принаймні {toString: () => string}, а в TS це не так
Проте навіть в тих місцях, де TS типи розходяться з теорією множин, все одно цікаво їх порівнювати: в чому саме розбіжність і чому розробники TS вирішили так

Ще раз: тезіс «типи в TS це множини з алгебри» — хибний, проте тезіс «якщо я буду ставитись до типів як до множин, то теорія множин мені зовсім ніяк не зможе допомогти» також хибний — зможе допомогти)

Це треш))) Мені здається, ви не розумієте, що ви пишите. В жодній сучасній мові програмування виведення типів не працює поверх теорії множин, ні наївної, яка схильна до протиріч типу парадоксів Кантора чи Рассела, ні до більш продвинутої системи аксіом Цермело-Франкеля, в якій неможливі ці парадокси. Сучасні мови використовую дикий мікс типізованого лямбда-калкулусу, теорії типів і формальної логіки. Деякі операції в системах типів дійсно надихались ідеями з теорії множин, але це трохи інше. Конкретно в TS виведення типів будується поверх спрощеної системи Хіндлі-Мільнера. Розширення до системи типів у вигляді Union та Intersection типів — це не теорія множин, бо, повторююсь, типи в TS не є множини.

Рекомендую: Kamareddine F., Laan T., Nederpelt R. — A Modern Perspective on Type Theory — 2004

Вітаю в сучасному світі розробки, де програмісти сиплять термінами, використовують AI, знають про десятки фреймворків, але не можуть пояснити, як це все працює та не можуть написати код без допомоги інструментів. ;)

давайте без діагнозов, і ви повторюєте одне і те ж саме 2-й раунд

«в жодній сучасній мові програмування виведення типів не працює поверх теорії множин» — де я доводив наступне? ви взагалі читали, що я написав?

давайте прочитаємо разом: «типи в тс не множини» !== «теорія множин не потрібна для глибшого розуміння мови програмування», що правда, адже якщо ставитись до типів як до множин, то можна зайти багато перетинів з теорією множин і з дискретною математикою в цілому — і це правда. Тому тезіс «теорія множин для глибшого розуміння мови програмування» — хибна.

Приємно доречі, що в свому коментарі ви навіть не намагаєтесь це «опровєргнуть».

Ну і ще раз, уявимо, що ви праві, і що тоді випливає з цього? Випливає, що для глибшого розуміння TS замість теорії множин необхідно знання системи Хіндлі-Мільнера, формальної логіки, теорії типів, лямбда-калкусу — супер! Оберіть собі хоча б одно з цього списку та вивчить, обіцяю вам зробити те ж саме зі свого боку

Тому тезіс «теорія множин для глибшого розуміння мови програмування» — хибна

а перед цим ви ж писали

для розуміння TS необхідні принаймні базові знання теорії множин

По моєму, пацієнт уже плутається у власних показаннях.

А якщо ще згадати, яким полотном нісенітниці ви це підкріпляли:

Якщо ставитись до типів як до множин, то можна побачити, що багато положень застосовані і до типів:
1. Концепція union-типів
2. Концепція intersection-типів
3. Концепція привласнень на поняттях під множина та супер множина
4. Концепція never як пустої множини
5. unknown як універсум

то стає абсолютно ясно, що ви не тільки не розумієте, про що пишете, ви ще і не хазяїн своїм власним словам.

Випливає, що для глибшого розуміння TS замість теорії множин необхідно знання системи Хіндлі-Мільнера, формальної логіки, теорії типів, лямбда-калкусу — супер

Ні, не випливає. Ви знову придумуєте у себе в голові якісь власні теорії і видаєте їх за реальність. Мною ж це було сказано до того, як це працює в мовах програмування з реальними типами загалом. І так, я по цьому всьому прочитав не одну книгу, щоб потім не попадатись на пустотрьопстві, на якому я підловив вас.

по-моєму очевидно з того, що написано раніше, що я написав з помилкою:

Тому тезіс «теорія множин не потрібна* для глибшого розуміння мови програмування» — хибна

І я не вірю, що ви цього не зрозумілі, якщо ви взагалі читаєте те, що я пишу, бо там з контексту все очевидно. Ви 3-й раз повторюєте одне і те саме в надії, що воно спрацює

Ні, не випливає.

Так а навіщо ви тоді то написали, якщо це ні на що не впливає? Щоб всі дізнались, що ви книжки читаєте?

Так а навіщо ви тоді то написали, якщо це ні на що не впливає? Щоб всі дізнались, що ви книжки читаєте?

Це впершу чергу для вас, щоб ви не писали дурниць про теорію множин і не придумували казки про те, що, наприклад,

Концепція never як пустої множини

коли перше посилання в гуглі приведе вас на статтю про пусті (bottom) типи, до яких відноситься never. Ну і, авжеж, щоб не заплутували читачів, видаючи їм власні видумки за правду.

у вас, як у інженера має бути добре розвинене абстрактне мислення та ви маєте чітко та добре відчувати та розуміти різницю між «never — це пуста множина» та «never та пуста множина мають багато спільних рис». Але якщо ви пишете такий коментар — то скоріше за все ця різниця опинилась за межами ваших книжок та вашего IDE

Доречі пуста множина також має властивість еквівалентну bottom типу

Блін, іди почитай про пусті типи. Ти просто зараз позоришся, як якийсь дурачок, який не здатний даже в банальний гугл пошук.

а стосовно функціонального програмування дуже просто:

розуміння того, що в функціональному програмуванні циклічні алгоритми реалізується за допомогою рекурсії допоможе зрозуміти, що в TS без рекурсивних типів не можна жити. Відповідно це допоможе відійти від справедливого патерну мислення в «традиційному» програмуванні, що «рекурсивні функції краще уникати без необхідности», і прийняти те, в TS типах рекурсія — це обовʼязкова база

Також розуміння концепції чистоти функцій допомогає вам зрозуміти, що ви можете цю функцію повністю на типах написати, адже результати чистих функцій можна статично передбачити, так само як і типи TS статично обчислюються:
наприклад уявимо функцію inferPathParams(route: string) => string[], яка примає рядок в якому записані path параметри: /myRoute/:id/myMusic/:songName/etc/etc2 а повертає масив [«id», «songName»]. Ця функція є чистою, відповідно можна спробувати написати тип InferredPathParams, який буде робити те ж саме але на рівні типів

який буде робити те ж саме але на рівні типів

А навіщо?

для створення API

наприклад ви хочете створити route loader для ReactRouter

і у вас таке api:

createBreadcrumbRouteLoader = (route: R, params: InferredRouteParams) => Promise

в рантаймі React Router буде в парамс класти значення path params, але по замовчуванню ви не отримуєте автокомпліт, бо params будуть Record, а цей тип дозволить отримувати type safety + приємний автокомпліт

Можна обійтись без цього, зробивши прямий assertion params, але знову ж таки — це не значить що InferredPathParams «бєсполєзний».

Ще раз: коли ви хочете створити ± просте та приємне API, щоб правильно описати його типо виведення потрібно буде трохи напрягтись і базовим TS тут не обійтись

От й виросло покоління IDE-driven розробників... Автокомпліт їм подавай... для API, яке раз на рік буде використовуватися... Ладно, лірику в сторону.

Коли ви захочете створити більш-менш просте та приємне API, то воно не буде повертати лише перелік змінних, а скоріше поверне складну структуру, в якій буде все, шлях, змінні шляху, пошукові параметри, хеш, тощо.

небачу нічого поганого в тому, щоб думати про DX. Все ж таки живемо в 2024 а не в 1984 і маємо змогу писати код не в терміналі домофона

небачу нічого поганого в тому, щоб думати про DX

Я теж не бачу нічого поганого в тому, що люди будуть мати значно гірший перфоманс ніж я. Мені це дозволить продавати свої продукти та послуги краще. ;)

Так а в чому суть, коли ці всі типи в прямому сенсі наголо вирізаються, коли код транспілюється в капусняк з джаваскрипта?

Та і може ви живите в 2024, але ви пробували латати хайлоад систему на сотню машин в час пік з кастомером, який оре тобі на вуху, коли з інструментів у тебе тільки ssh і vim?

Який-такий vim? Там немає автокомпліта! А це десь відсотків 90 розробників одразу робить «інвалідами».

90% розробників не можуть розвернути стрічку не використовуючи .reverse(), але на повних щах готові захищати функціональне програмування, про яке читали на вікі))

Це в них ще не вимагали алгоритмів, які оптимальні по памʼяті чи cpu ;)

Панове, зніміть вже собі номер 👬

А ви що довести питаєтесь? Кінцева мета яка? Що DX це «блажь буржуазних зумєров», як не можуть самостійно лінійний пошук без копілоту написати? Так ви неправі повністю, навіть не намагайтесь сюди лізти

Приклад що ви навели — едж кейс, цим не можна щось аргументувати

Тим паче ви так кажете, що якщо ви щось складніше ніж as any напишите, то у вас закінчиться оперативна памʼять на вашому М12 Pro Ultra Max на 512 ОЗУ. Це не правда. Щоб ваша IDE стала повільною — це прямо треба постаратись зробити (хоча я впевнений, що у вас вийде запросто)

Не відходьте від бази: будь-який більш менш простий API вимагає мінімум x2 типізації і все. І це важливо для того, що API можна було нормально користуватись і воно було type safe. DX вже іде бонусом, це не самоціль

А ви що довести питаєтесь?

Нічого. Це просто приклад того, що за межами IDE також є життя.

Приклад що ви навели — едж кейс

Oh, my sweet summer child.
Ясно, що в нормальному продукті з хайлоадом ви ще не працювали. Нічого, надіюсь, все ще буде.

Не відходьте від бази: будь-який більш менш простий API вимагає мінімум x2 типізації і все.

Світ не закінчується на тайпскрипті. Навіть в тому ж світі JS є просто безліч бібліотек, які не мають TS і типів, і ніхто ще не помер.

Нічого.

нарешти зізнались, що нічого конструктивного донести навіть і не намагались. Думав не дочекаюсь

Чудовий сенс буття розробника!

Тут мені на днях потрібно було рекурсивно обійти граматику SQL-діалекту від TDEngine і я взяв TypeScript. Авжеж, я міг взяти будь-який готовий LALR парсер, але хотілось трохи позабавлятися з рекурсією. Банальний обхід граматики зразу звалився з переповненням стеку, бо в TS немає хвостової рекурсії, бо TS не функціональна мова програмування.

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

Тому питання про те, чому

знання теорії функціонального програмування є обовʼязковим

залишається відкритим. (Для себе я знаю відповідь, що воно абсолютно опціональне в контексті TS).

Боже, ви прочитали 1000000 книжок але вам це не допомогає

твердження «в TS немає хвостової рекурсії» — це брехня
правдиве твердження: «TS компілятор* не виконує оптимізацію* хвостової рекурсії»

також брехня: «відсутність TCO робить мову нефункціональною», адже функціональність мови ніяк не залежить від того, які оптимізації може робити конкретний компілятор.

Якщо б ви хоч трохи були обізнаними в темі, ви би могли зауважити: ось TS не підтримує High Order Types, тому це не функціональна мова програмування на рівні типів*. Але куди вам там до того — так само як обходите граматику SQL діалектів, ось так сама формальна логіка вирішила обійти і вас

Очевидно, що ви не прочитали жодної, а тому банальна необізнаність в парі з впертістю ставить вас в позицію потреби в постійному захисті та виправдовуванні. Також дуже кидається в очі те, що за відсутності нормальної аргументації, ви пробуєте парирувати словечками погостріше, приписати мені ярлики «брехуна» і тд, які не тільки не роблять вам лиця, а ще і підтверджують мною сказане про банально необізнаність.

«в TS немає хвостової рекурсії» — це брехня

Це не брехня, як би ви собі в голові це не придумували і не виправдовували. Це факт.

також брехня: «відсутність TCO робить мову нефункціональною»

Ще одна ваша фантазія. TS має типові functional programming capabilities, які притаманні більшості C-подібних мов програмування. Але це не чиста функціональна мова програмування і, думаю, ніколи такою не буде.

Але куди вам там до того — так само як обходите граматику SQL діалектів, ось так сама формальна логіка вирішила обійти і вас

Хочеться вірити, що ви хоть погугли, що ці слова значать, бо знову виглядає, як повне профанство.

Я пишу це без жодної агресії чи з метою принизити. Зрозуміло, що для звичайного фронтендщика всякі там «хвостові рекурсії», «лалр-парсери» та «хіндлі-мільнери» — це з області високих матерій. Тому, переступіть черес власне невігластво, візьміть розумну книгу і розберіться в питанні.

Очевидно, що за весь час дискусії ви не змогли нічого сказати нового окрім купи дивних тез без доказів, ще і по 4 рази повторили, щоб всі зрозуміли, що ваші книжки вам не допомгли

Нуль аргументів, чому в ТС нема хвостової рекурсії
Нуль аргументів, чому ТС нефункціональна мова
Нуль аргументів, чому теорія множин не потрібна в ТС

Як мантру повторюєте одне і те саме, в надії, що я з 10-го разу просто вам повірю на слово. В принципі ведете себе як справжній інженер, коли в нього щось не білдиться — нічого не поміняй і запусти знову, може проконає

Я пишу це без жодної агресії чи з метою принизити. Зрозуміло, що для звичайного Go/Python/Node розробника, який вміє бездоганно писати код на домофоні сосєда без жодного UI, правильно підбирати слова та розуміти щось в там в теорії множин та кванторах — це занадто по-хіпстерськи та недостатно брутально

Нуль аргументів, чому в ТС нема хвостової рекурсії

Ахахааха, чел, ти закопуєш себе все глибше і глибше. Не надоїло ще позоритись? Це уже починає виглядати, як проста вперта тупість.

Аххахаха, чел, я в TS не можу написати так:

function factorial(n: number, acc: number = 1) {
 if (n <= 1) return acc;
 return factorial(n - 1, n * acc);
}

це хвостова рекурсія, прикинь. Те, що компілятор її не оптимізує це не значить, що її не існує, і не значить що TS нефункціональна мова. Це значить рівно те, що значить: в TS відсутня TCO.

Спроба з цього факту зробити сенсацію лише демонстрація власного невігластва

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

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

З того, що я зрозумів із цього треду, TypeScript дозволяє писати хвостову рекурсію, але самостійно її не оптимізує. Тобто, рекурсивний код може працювати, але без оптимізації є ризик переповнення стеку. У цьому питанні весь це спір?

Я знайшов таке issue на GitHub: github.com/...​t/TypeScript/issues/32743, де пропонується додати в TypeScript підтримку оптимізації хвостових викликів (TCO).

Якщо подивитися на розділ TypeScript Design Goals
у документації, зокрема на пункт «no-goals» (github.com/...​pt-Design-Goals#non-goals), то видно, що TypeScript було створено для компіляції, а не для оптимізації коду.

Ні, чел написав, що для кращого розуміння роботи типів в ТС, потрібно обов’язково занурюватись в функціональне програмування і знання теорії множин, бо йому здалось, що юніот-тип має відношення до юніон операції над множинами.

А про оптимізацію хвостової рекурсії я вже нижче прикріпляв пр від автора ТС.

твердження «в TS немає хвостової рекурсії» — це брехня
правдиве твердження: «TS компілятор* не виконує оптимізацію* хвостової рекурсії»

С какого перепугу это вранье?

В Typescript не прописана обязательная оптимизация хвостовой рекурсии. Точка.

Есть языки, в которых она прописана как обязательная.

Например, в Scheme прописано в каких случаях требуется выполнить оптимизацию хвостовой рекурсии.

Или, например, в Scala есть специальный атрибут, который требует чтоб при вызове функции выполнялась оптимизация хвостовой рекурсии, и комплиятор должен выдать ошибку, если это невозможно.

Ви плутаєте терміни «хвостова рекурсія» та «оптимізація хвостової рекурсії». В компіляторі тайпскрипту відсутне друге, а перше можна запросто створити

Тому я і написав, що написав:

твердження «в TS немає хвостової рекурсії» — це брехня
правдиве твердження: «TS компілятор* не виконує оптимізацію* хвостової рекурсії»
С какого перепугу это вранье?

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

Я тут доречі проглянув старі пр в репо тайпскрипта і знайшов один цікавий від самого пана Хейлсберга.

LastMember<U> =
   Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never;?LastMember<U> =
   Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never;?LastMember<U> =
   Intersection<U extends U ? (x: U) => 0 : never> extends (x: infer L) => 0 ? L : never;

дуже зручно та легко читається, ага

Шикарна стаття! Люблю Тайпскрипт, але для буденної роботи усі ці можливості не використовував би.

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