Айсберг брендованих типів у TypeScript: від основ до глибин

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

Всім привіт! Мене звати Владислав, я — Frontend Engineer в OBRIO, з екосистеми Genesis. Активно досліджую Advanced концепції в TypeScript і просуваю такі скіли та знання у своїй команді. Сьогодні ми поговоримо про один з найбільш, на мою думку, контроверсійних та недооцінених інструментів TypeScript — брендовані типи (Branded Types) або тавровані типи — кому як більше подобається. В статті я буду використовувати всі ці три назви як абсолютні синоніми.

Мета статті — уніфікувати та класифікувати всі наявні знання про цей інструмент мови TypeScript, зокрема use case-и та різновиди. Окрім цього, я хочу розвіяти всі міфі про Branded Types у TypeScript-і зробити власні висновки на основі цієї інформації.

Передісторія

Проблема брендованих типів постає дуже рано перед усіма, хто тільки почав вивчати TypeScript: коли TypeScript Handbook на самому початку вводить нас в type aliases:

type ID = string | number;

то нам у наступному реченні кажуть, що це саме alias-и, а не «принципово нові» типи. Бо TypeScript має структурну типізацію, а не номінальну, і, відповідно, через type alias-и створювати «номінальні» типи не вийде:

type Email = string;

declare function validateEmail(email: Email): boolean;

const email: Email = '[email protected]';
const whateverString: string = 'whateverString';

validateEmail(email); // Все ок
validateEmail(whateverString); // І тут немає ніякої помилки - а так хотілось

Але початківець в TypeScript не здається на цьому моменті — він повний ентузіазму, тому йде далі шукати, як можна обійти цю проблему, і дуже швидко він знайде наступний приклад:

type Email = string & {__brand: "Email"};

declare function validateEmail(email: Email): boolean;

const email: Email = '[email protected]' as Email;
const whateverString: string = 'whateverString';

validateEmail(email); // Все ок
validateEmail(whateverString); // Нарешті бажана помилка: string is not assignable to Email

Новачок знаходить, що можна «дочіпити» до примітовного типу певну віртуальну властивість з унікальним значенням, якої ніколи не буде в runtime, але яка буде існувати на type-level та таким чином фактично створювати бажану номінальність для типу.

Також він дізнається, що ці типи прийнято називати branded, бо вони буквально «затавровані» спеціальною властивістю, яка грає роль унікального ідентифікатора для цього типу. До речі, номінальні системи типів, наприклад як в C++, так і працюють: сильно спрощуючи, кожному типу привласнюється ідентифікатор, який і використовується для перевірки однаковості типів.

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

type Brand<TPrimitive, TBrand> = TPrimitive & { __brand: TBrand };

type Email = Brand<string, "Email">;

declare function createUser(email: Email): Promise<{ success: true }>;
declare function setEmail(email: Email): void;

type CustomSubmitEvent = { target: { value: string } };
type CustomChangeEvent = { target: { value: string } };

function handleSubmit(event: CustomSubmitEvent) {
 createUser(event.target.value as Email);
}

function handleChange(event: CustomChangeEvent) {
 setEmail(event.target.value as Email);
}

const email = '[email protected]' as Email;

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

І ця критика є абсолютно справедливою. Ще більшим розчаруванням для новачка буде той факт, що навіть в прикладах коду найбільшого амбасадора брендованих типів та Advanced TypeScript загалом — Метта Покока (Matt Pocock) — також присутній as hell. І це попри те, що його курс Total TypeScript доволі непогано розкриває use case-и Branded Types. Той факт, що навіть в коді-метра з теми присутня така проблема, може створити хибне уявлення, що це неминучий трейдофф використання брендованих типів, з яким доведеться жити. А в ще гіршому випадку — що тавровані типи це просто іграшка для TypeScript nerd-ів.

Таким чином, основне, за що критикують Branded Types, це: збільшення абстракції коду з погіршенням читабельності та постійним використанням anti-pattern-у; незрозумілі або нечіткі межі застосування/use-саse-и цього інструменту. Далі в статті ми розберемо, чому вся ця критика часто є сильно перебільшеною і чому тавровані типи є водночас потужним та інтуїтивно-зрозумілим інструментом мови програмування.

З чого все почалось

Пропоную почати з останнього аргументу критиків: незрозумілі/нечіткі межі використання/use-case-и таврованих типів. Щоб розібратись, коли можна застосувати брендовані типи в коді (щоб отримувати від них потенційну користь), треба розібратись з історією того, як на світ зʼявився найперший таврований тип.

Я не просто так обрав для приклада email-и: що таке email з точки зору системи типів? Це рядок (string):

declare function sendEmail(email: string): Promise<void>;

Але зрозуміло, що в runtime це буде не довільний рядок, а рядок, який має відому особливу структуру:

declare function sendEmail(email: string): Promise<void>;

const email = '[email protected]';

sendEmail(email);

І, відповідно, дуже хочеться захиститись від таких дурних помилок ще на рівні типів:

declare function sendEmail(email: string): Promise<void>;

const email = '[email protected]';

sendEmail(email);

sendEmail('whatever'); // Тут хочеться бачити помилку, оскільки ми явно не email надсилаємо

Для цього хочеться мати Type Email, який буде містити в собі необхідну TS логіку по перевірці того, що заданий літерал є валідним email-ом:

type Email = // ???

Але при спробі створити такий тип дуже швидко стає зрозуміло, що це дуже нетривіальна задача (якщо вона взагалі має рішення). Бо в runtime описується не самим простим регулярним виразом, і ніхто не гарантує, що його можна виразити на рівні типів через можливості TypeScript. Перша найпростіша думка написати такий template literal:

type Email = `${string}@${string}.${string}`;

declare function sendEmail(email: Email): Promise<void>;

const email = '[email protected]';

sendEmail(email);
sendEmail('whatever'); // Тут вже помилка є
sendEmail('@.@') // Але можна вигадати багато контр-прикладів, які ламають цей літеральний тип

І хоча він відбраковує очевидно-неправильні рядки, все одно існує безліч контрприкладів, які його ламають. На цьому етапі розробник стоїть на розгалуженні: шукати цей суперточний тип (і можливо дійти до того, що його неможливо виразити) або зробити простіше: визнати, що Email є достатньо складним примітивом, який неможливо або дуже нетривіально виразити «конвенціональними» засобами мови TypeScript, і тому позначити його так:

type Email = string & { isEmail: true };

А ось звідки береться ця властивість isEmail, буде розказувати runtime функція-утіліта isEmail:

type Email = string & { isEmail: true };

function isEmail (probablyEmail: string): probablyEmail is Email {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(probablyEmail);
}

А далі в коді це буде використовуватись так:

type Email = string & { isEmail: true };

function isEmail(probablyEmail: string): probablyEmail is Email {
 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(probablyEmail);
}

declare function sendEmail(email: Email): Promise<void>;

const email = '[email protected]';

if (isEmail(email)) {
 sendEmail(email); // Жодного as приведення
}

В цей момент часу на світ зародився перший брендований тип, і, відповідно, було отримано перше визначення брендованого типу:

брендований тип Branded<T, B> є таким примітивним типом даних, runtime структуру якого неможливо або дуже складно передати через type-level синтаксис TypeScript, тому щоб розкрити, що це саме за runtime структура даних, використовуються додаткові runtime чисті функції-утиліти, які й містять інформацію про цю структуру, а на рівні типів до примітивного типу додається унікальна type-level only властивість з унікальним значенням, як ідентифікатор брендованого типу.

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

Визначення Branded<T, B>

Спочатку розберемось з ним на рівні TypeScript. Ми згадали певну сутність Branded<T, B>. Це тайп-хелпер, який ми будемо використовувати далі й завжди для створення брендованих типів. Спробуємо дати їх визначення:

type Branded<TPrimitive, TBrand extends string> = TPrimitive & { __brand: TBrand };

Цього визначення вже достатньо, щоб представити брендований тип, але тут __brand це просто звичайна string-властивість, і, відповідно, через таке визначення у наших сутностей:

  1. По-перше, у всіх auto-complete вашої IDE буду в списку властивостей.
  2. По-друге, цю властивість TypeScript дозволить перевизначити, що створить Runtime помилку, оскільки у примітивів не можна визначати властивості через звичний синтаксис.
type Branded<TPrimitive, TBrand extends string> = TPrimitive & { __brand: TBrand };

declare const x: Branded<string, "Email">;

x.__brand //accessible;

x.__brand = '132131' as 'Email'; // assignable;

Щоб виправити перші дві проблеми, достатньо використати замість string — unique symbol. Тоді вже властивість __brand стане по-справжньому фантомною:

declare const __brand: unique symbol;
type Branded<TPrimitive, TBrand extends string> = TPrimitive & { [__brand]: TBrand };

declare const x: Branded<string, "Email">;

x.__brand //no longer accessible;

x.__brand = '132131' as 'Email'; //no longer assignable, because non-accessible;

Таким чином, канонічним та найбільш поширеним визначенням Branded<T, B> в TypeScript є саме таке визначення:

declare const __brand: unique symbol;
type Branded<TPrimitive, TBrand extends string> = TPrimitive & { [__brand]: TBrand };

Аналіз визначення

Розберемось тепер з наслідками текстового визначення брендованого типу. Нагадую, що ми зараз розбираємось з межами застосування Branded Types, і, перше визначення безкоштовно дає зрозуміти ці межі:

1. Таврованими можуть бути лише примітиви, ніяких брендованих обʼєктів в цьому сенсі не буває і бути не може. Чому так: визначення каже нам, що Branded типи потрібні тоді, коли неможливо через TypeScript виразити runtime-структуру значень цього типу, а для складних типів даних (обʼєктів) ми завжди можемо описати необмежено складну структуру, бо ми можемо додавати та комбінувати властивості обʼєктів JS так, як завгодно.

2. Для кожного branded типа мають бути створені чисті функції-утиліти, які й будуть розкривати те, що ми не змогли передати через TS: його runtime-структуру. Чому ці функції мають бути чистими? Тому що з самого початку ми намагались створити TS тип, а всі типи в TypeScript обчислюються статично і являють собою чисті функції над типами, що залежать лише від константної інформації, відомої компілятору. А оскільки наші функції-утіліти передають те, що ми не змогли передати через TS — вони також мають бути чистими.

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

А які взагалі можна створити утіліті-фукнції для мого брендованого типу?

Насправді їх багато не треба, достатньо принаймні однієї isYourBrandedType функції (в прикладі вище: isEmail).

declare const __brand: unique symbol;
type Branded<TPrimitive, TBrand> = TPrimitive & { [__brand]: TBrand };

type Password = Branded<string, 'Password'>;

function isPassword(probablyPassword: string): probablyPassword is Password {
 return probablyPassword.length > 6;
}

Ця функція виконує 2 задачі:

  1. Слугує само-документацією для вашого брендованого типу
  2. Приховує as-cast через predicate return type probablyPassword is Password.

Так, саме приховує, бо насправді assertion нікуди не дівся, оскільки type predicate це по суті теж assertion, бо ніхто вам не забороняє набрехати цьому предикату в тілі функції:

function isPassword(probablyPassword: string): probablyPassword is Password {
 return 2 > 5;
}

Але не треба засмучуватись через це. Насправді всі advanced скіли мови TypeScript призначені саме для того, щоб приховувати свої assertion-и за іншими, більш «інтелектуальними» TypeScript конструкціями. TypeScript це в першу чергу про створення гнучких, «еффектних» та само-документуючих API для розробників, а не про безпечну типізацію.

Повернемось до функції isPassword. Тепер ми можемо її використовувати для створення type guard-ів:

declare function changePassword(newPassword: Password): Promise<void>;

const password = '12312312312';

function isPassword(probablyPassword: string): probablyPassword is Password {
 return 2 > 5;
}

if (isPassword(password)) {
 changePassword(password);
}

changePassword(password); // Не працює без type guard-у

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

Таким чином використання брендованих типів самодокументує ваш код: без всяких README, notion-ів та коментарів під час code review — мінімальні інвестиції в написання малого TS-коду, які дають неймовірний ауткам.

Нашої функції-type guard достатньо для того, щоб покрити всі потреби для використання branded типів. Але іноді треба досягти того самого результату (приведення string в branded-тип) іншими засобами, для цього краще мати ще додаткові синтаксичні інструменти.

Я пропоную для кожного branded-типу створювати не одну лише функцію type-guard, але ще й функцію-assertor та функцію-parser.

Функція-assertor працює дуже схожим чином як і predicate type, але вона дозволяє не створювати вкладеність та не писати if-конструкції:

function assertPassword(probablyPassword: string): asserts probablyPassword is Password {
 if(!isPassword(probablyPassword)) {
   throw new TypeError();
 }
}

declare function changePassword(newPassword: Password): Promise<void>;

const password = '12312312312';

changePassword(password); // Не працює

assertPassword(password); // до цього рядку password є string, після цього - є Password

changePassword(password); // Працює

Assertor-и в TypeScript — це функції, які як і type guard-и роблять перевірку типу, але у випадку невідповідності викидають exception, а не вертають false.

Якщо перевірка успішна, то assertor просто нічого не вертає. На практиці це означає, що якщо управління кодом пішло далі виклику assertor-а — отже, аргумент відповідає заявленому типу, і TypeScript це враховує також. У прикладі в playground-і треба порівняти тип password до assertPassword та після: в першому випадку буде string, в другому — вже Password.

І третій інструмент-утиліта для брендових типів це парсер. Уявимо, що хочемо просто створити брендований тип з «потенційно-брендованого значення». Зрозуміло, що так ми писати не будемо:

const password = '12312312312' as Password;

Бо ми домовились, що більше ніяких as-cast-ів. З наявними інструментами ми можемо зробити так:

const _password = '12312312312';
const password: Password | null = isPassword(_password) ? _password : null;

Це виглядає максимально коряво і незручно у використанні, а хотілося б мати таке API:

const password = parsePassword('12312312312'); // виводиться як Password, а не string

У своїй реалізації парсер просто робить assertion-check і вертає той самий аргумент без змін:

function parsePassword(probablyPassword: string): Password {
 assertPassword(probablyPassword);
 return probablyPassword;
}

Це виглядає як магія, але приведення типу приховується саме у виклику assertPassword — якщо управління піде далі, значить probablyPassword дійсно є Password.

Таким чином ми створили брендовий тип та три інструменти, які дозволяють з ним зручно працювати та приховувати наші assertion-и завдяки синтаксичним можливостям мови програмування TypeScript:

declare const __brand: unique symbol;
type Branded<TPrimitive, TBrand> = TPrimitive & { [__brand]: TBrand };

type Password = Branded<string, 'Password'>;

function isPassword(probablyPassword: string): probablyPassword is Password {
 return probablyPassword.length > 6;
}

function assertPassword(probablyPassword: string): asserts probablyPassword is Password {
 if(!isPassword(probablyPassword)) {
   throw new TypeError();
 }
}

function parsePassword(probablyPassword: string): Password {
 assertPassword(probablyPassword);
 return probablyPassword;
}

Прошу звернути увагу також на те, як кожна наступна функція-утиліта використовує в своїй реалізації попередню.

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

  1. На рівні TypeScript: роблять необхідні типо-приведення та звуження типів.
  2. На рівні Runtime: наслідок їх використання гарантує виконання певної логіки, повʼязаної та закладеної вами у ваш брендований тип.

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

Отже, узагальнимо все те, що ми на поточний момент цієї статті знаємо про брендовані типи:

  1. Брендований тип — це такий примітивний тип даних, який має особливу структуру в runtime, яку неможливо або занадто важко виразити через нативні засоби TypeScript.
  2. Тому на рівні TypeScript ми декларуємо цей тип просто як обʼєднання примітивного типу (string або number) з обʼєктом з однією унікальною властивістю з унікальним значенням. Ця властивість існуватиме виключно на рівні типів, щоб дозволяти компілятору відрізняти наш тип від власне самого примітива, а ще точніше — вважати наш новий тип «вужчим» за оригінал, створюючи квазіномінальний тип.
  3. На рівні JavaScript ми створюємо 3 утіліти-функції: isMyBrandedType, assertMyBrandedType та parseMyBrandedType, кожна з яких виражається через попередню, а isMyBrandedType зберігає у своїй перевірці ту саму «структуру», яку ми не змогли виразити через TypeScript. Всі ці 3 утиліти-функції оголошуються поруч з декларацією самого типу.
  4. Неможливість створення цих функцій утиліт свідчить про те, що те, що ми хочемо затаврувати, не підпадає під визначення брендованого типу та не потребує використання цього інструменту.
  5. Таким чином ми досягаємо і type-safety при роботі з цією структурою даних, а також феномену самодокументації проєкту. Адже для роботи з нашим типом розробникам доведеться або писати as-hell, або використовувати наші функції-утиліти.
  6. Головний use-case брендованих типів: виокремлення важливих примітивних сутностей в проєкті, про які ми знаємо дещо більше, ніж що це string або number та уніфікація API роботи з ними.
  7. Також ми на цьому етапі домовились, що брендованих обʼєктів не буває, згідно з нашим визначенням в пункті 1.

Стандартне API для створення брендованих типів

Оскільки ми зʼясували, що для комфортної роботи з брендованими типами треба створювати додаткові функції-утиліти, було б добре мати загальне API createBrandedType, щоб уніфікувати та зменшити boilerplate при створенні нових брендованих типів:

declare const __brand: unique symbol;
type Branded<TPrimitive, TBrand> = TPrimitive & { [__brand]: TBrand };

type Password = Branded<string, 'Password'>;

function isPassword(probablyPassword: string): probablyPassword is Password {
 return probablyPassword.length > 6;
}

function assertPassword(probablyPassword: string): asserts probablyPassword is Password {
 if(!isPassword(probablyPassword)) {
   throw new TypeError();
 }
}

function parsePassword(probablyPassword: string): Password {
 assertPassword(probablyPassword);
 return probablyPassword;
}

type Email = Branded<string, 'Email'>;

function isEmail(probablyEmail: string): probablyEmail is Email {
 return probablyEmail.includes('@');
}

function assertEmail(probablyEmail: string): asserts probablyEmail is Email {
 if(!isEmail(probablyEmail)) {
   throw new TypeError();
 }
}

function parseEmail(probablyEmail: string): Email {
 assertEmail(probablyEmail);
 return probablyEmail;
}

В цьому прикладі ми створюємо два брендованих типи: Password та Email, а також функції-утиліти для них. Легко побачити, що тут дуже багато подібних патернів. Єдине, що принципово відрізняється в цих двох типах — це тіло функцій isPassword та isEmail (що справедливо, адже це ядро інформації про наш брендований тип), а їхні assertor-и та парсери однаковим чином виражаються через is-функцію. Відповідно і нашу createBrandedType можна буде задекларувати як функцію, яка залежить від двох параметрів: власне імʼя бренду та тіло is-функції. Всю решту можна вирахувати автоматично та повернути:

const {
 EmailCanonical,
 isEmail,
 assertEmail,
 parseEmail
} = createBrandedType('Email', (probablyEmail: string) => probablyEmail.includes('@'));

type Email = typeof EmailCanonical;

Саме таке API ми хочемо: createdBrandType приймає «назву» бренду та «предікат», по якому примітив буде звужуватись до брендованого типу. На основі цього createdBrandType вертатиме обʼєкт з чотирма властивостями: isBrandName, assertBrandName, parseBrandName та BrandNameCanonical — фіктивний обʼєкт, який буде потрібний лише щоб на рівні типів отримати його тип як тип самого бренду.

Перейдемо до покрокової реалізації цього API. Спочатку визначимось з самим визначенням Branded<TPrimitive, TBrand>. Його реалізація через unique symbol є найбільш популярною та пропонується вже згаданим метром брендованих типів Метом Пококом:

declare const __brand: unique symbol;
type Branded<TPrimitive, TBrand> = TPrimitive & { [__brand]: TBrand };

Наш тип дуже простий, і лише «додає» одне віртуальне проперті до TPrimitive. Тут вважаю потрібним одразу сказати, чому я назвав цей тип Branded, а не Brand. Існує практика називати type-helper-и в TypeScript (типи, які приймають інші типи та роблять з ними корисні перетворення) таким самим патерном, як і runtime функції — дієсловом. Бо вони, як і функції, приймають аргументи й певним чином їх перетворюють.

Однак в цій статті я використовую інший патерн: оскільки тип сутності має відповідати на питання «що?» або принаймні «який» (бо коли ми бачимо в коді невідому змінну та наводимо курсор, щоб IDE підсвітила її тип, то в голові тримаємо саме питання «що це?» або «якого типу ця сутність?»). Тому замість того, щоб називати свої типи як GetObjectValues<T>, Override<T>, Rename<T, K>, Brand<P, B> тощо; я їх називаю як ObjectValues<T>, Overridden<T>, Renamed<T, K>, Branded<P, B>. Коли я бачу сутність, яка типізована як GetObjectValues<T>, то очікую, що вона собою являє функцію getObjectValues(obj), а не значення обʼєкта obj. Я не навʼязую такий патерн неймінгу типів та тайп-хейлепрів, а цей абзац додав суто для того, щоб було зрозуміло, чому типи в цій статті називаються саме так, як називаються.

Отже, повернемось до нашого API. Далі пропоную визначити на рівні типів типи наших трьох функцій IsFunction, Assertor та Parser:

type IsFunction<TPrimitive, TBrand> = (probablyBrand: TPrimitive) => probablyBrand is Branded<TPrimitive, TBrand>;
type Assertor<TPrimitive, TBrand> = (probablyBrand: TPrimitive) => asserts probablyBrand is Branded<TPrimitive, TBrand>;
type Parser<TPrimitive, TBrand> = (probablyBrand: TPrimitive) => Branded<TPrimitive, TBrand>;

Ці три типи просто визначають контракт того, яка функція є IsFunction, яка є Assertor, а яка є Parser.

Далі задекларуємо наш API на рівні типів:

type BrandedTypeApi<TPrimitive, TBrand extends string> = {
 [B in TBrand as `is${B}`]: IsFunction<TPrimitive, TBrand>; } & {
 [B in TBrand as `assert${B}`]: Assertor<TPrimitive, TBrand> } & {
 [B in TBrand as `parse${B}`]: Parser<TPrimitive, TBrand>; } & {
 [B in TBrand as `${B}Canonical`]: Branded<TPrimitive, TBrand>;
}

Це обʼєкт з тими самими чотирма властивостями, які буде вертати наша функція createBrandedType: isBrandName, assertBrandName, parseBrandName, BrandNameCanonical

Далі йде сама реалізація функції createBrandedType:

 
function createBrandedType<
 TPrimitive,
 TBrand extends string,
>(brandName: TBrand, isBrand: (probablyBrand: TPrimitive) => boolean): BrandedTypeApi<TPrimitive, TBrand> {
 const isFunctionName = `is${brandName}`;
 const assertorName = `assert${brandName}`;
 const parserName = `parse${brandName}`;
 const canonicalName = `${brandName}Canonical`;

 type TheBrand = Branded<TPrimitive, TBrand>;

 function isFunction(probablyBrand: TPrimitive): probablyBrand is TheBrand {
   return isBrand(probablyBrand);
 }

 function assertor(probablyBrand: TPrimitive): asserts probablyBrand is TheBrand {
   if (!isFunction(probablyBrand)) {
     throw new TypeError();
   }
 }

 function parser(probablyBrand: TPrimitive): TheBrand {
   assertor(probablyBrand);
   return probablyBrand;
 }

 return {
   [isFunctionName]: isFunction,
   [assertorName]: assertor,
   [parserName]: parser,
   [canonicalName]: {} as TheBrand,
 } as BrandedTypeApi<TPrimitive, TBrand>;
}

Ця дженерік функція, як ми і домовились, приймає назву бренду (і виводить її літерал в дженерік параметр TBrand) та «предікат», який містить перевірку будь-якої складності, що примітив є брендом (виводить тип аргументу предикату як дженерік параметр TPrimitive). Обмеження extends string на TBrand додано саме для того, щоб TBrand виводився як літерал, а не як загальний примітив string.

Далі в тілі визначаються назви властивостей обʼєкта для нашого BrandedTypeAPI:

 const isFunctionName = `is${brandName}`;
 const assertorName = `assert${brandName}`;
 const parserName = `parse${brandName}`;
 const canonicalName = `${brandName}Canonical`;

Потім локально декларується тип TheBrand тільки для того, щоб по тілу функції не повторювати постійно Branded<TPrimitive, TBranded>:

type TheBrand = Branded<TPrimitive, TBrand>;

Потім визначається is-функція, ассертор та парсер:

 function isFunction(probablyBrand: TPrimitive): probablyBrand is TheBrand {
   return isBrand(probablyBrand);
 }

 function assertor(probablyBrand: TPrimitive): asserts probablyBrand is TheBrand {
   if (!isFunction(probablyBrand)) {
     throw new TypeError();
   }
 }

 function parser(probablyBrand: TPrimitive): TheBrand {
   assertor(probablyBrand);
   return probablyBrand;
 }

isFunction в цій реалізації повністю повторює логіку isBrand, просто додає type-predicate в return type, бо оригінальна функція isBrand повертає просто boolean (якого буде недостатньо для звуження примітиву до бренда в умовах). Функція assertor реалізована через isFunction, а parser — через assertor. Далі всі ці 3 функції + canonical вертаються одним обʼєктом.

return {
   [isFunctionName]: isFunction,
   [assertorName]: assertor,
   [parserName]: parser,
   [canonicalName]: {} as TheBrand,
 } as BrandedTypeApi<TPrimitive, TBrand>;

В цій реалізації canonical є пустим обʼєктом, але це не є важливим — бо в runtime це значення все одно не буде використовуватись і воно потрібне лише для того, щоб потім зробити від нього typeof та отримати тип бренду:

const {
 EmailCanonical,
 isEmail,
 assertEmail,
 parseEmail
} = createBrandedType('Email', (probablyEmail: string) => probablyEmail.includes('@'));

type Email = typeof EmailCanonical;

const {
 PasswordCanonical,
 isPassword,
 assertPassword,
 parsePassword
} = createBrandedType('Password', (probablyPassword: string) => probablyPassword.length > 6);

type Password = typeof PasswordCanonical;

Отже, ми отримали єдиний API для створення брендованих типів у проєкті. Звісно, що createBrandedType можна реалізувати по-іншому — наприклад, без canonical. Але головне, що наявність будь-якого API для створення брендованих типів у проєкті гарантує те, що всі типи будуть створені згідно з їх визначення та єдиним чином. Тобто користь від їх використання в проєкті буде максимальною і без as-hell-у.

Висновки з вершини айсберга

Отже, на поточний момент ми дізнались та дійшли наступних висновків:

  1. Брендований тип — примітивний тип даних з характерною runtime структурою, яку дуже складно або неможливо виразити через засоби мови програмування TypeScript.
  2. На рівні типів Branded тип визначається як перетин примітиву та обʼєкту з єдиним унікальним проперті з унікальним значенням.
  3. А на рівні JS створюються чисті функції-утіліти, реалізація яких і буде містити перевірки на цю унікальну структуру.
  4. Неможливість створення таких чистих функцій-утіліт свідчить про те, що сутність, яку ми хочемо позначити як бренд — брендом не є насправді.
  5. Обʼєкти в цьому визначенні не можуть бути брендованими.
  6. Для спрощення та уніфікації роботи з брендованими типами створили API createdBrandedType(brandName, isBrand), яке, зокрема, не дозволить створити брендований тип без функцій-утіліт (вимагає виконання вимог пунктів 3 та 4).
  7. Зʼясували, що використання брендованих типів дозволяє підвищити рівень типо-безпеки проєкту, писати самодокументуючий код, а також змушувати використовувати в розробці саме той набір інструментів, який ви для цього передбачили.

Приклад використання знань з вершини айсберга

Розглянемо простий приклад, як можна використати отримані знання на практиці.

Уявимо собі певну систему, де є три бізнес сутності: Project, User та Product:

type Project = {
 id: string;
 title: string;
}

type User = {
 id: string;
 email: string;
}

type Product = {
 id: string;
 price: number;
}

Всі ці [спеціально спрощені в демонстраційних цілях] сутності мають ідентифікатор. Нехай ми знаємо, що в них всіх ідентифікатор являє собою uuid. Uuid цілком підпадає під визначення брендованого типу: примітив (рядок), runtime структуру якого неможливо або дуже складно виразити стандартними засобами мови TypeScript.

Тому слушна ідея створити брендований тип Uuid для цього:

declare function isUuid4(payload: unknown): boolean;
const uuidBrand = createBrandedType('Uuid', (probablyUuid: string) => isUuid4(probablyUuid));

type Uuid = typeof uuidBrand.UuidCanonical;

type Project = {
 id: Uuid;
 title: string;
}

type User = {
 id: Uuid;
 email: string;
}

type Product = {
 id: Uuid;
 price: number;
}

Дисклеймер: передбачається, що функція isUuid4 йде з певної бібліотеки.

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

Далі по коду ми зможемо його використовувати, наприклад, наступним чином:

declare function getProject(projectId: Uuid): Promise<Project>;
declare function deleteProject(projectId: Uuid): Promise<Project>;
declare function getUser(userId: Uuid): Promise<User>;
declare function deleteUser(userId: Uuid): Promise<User>;
declare function getProduct(productId: Uuid): Promise<Product>;
declare function deleteProduct(productId: Uuid): Promise<Product>;

Щоб викликати всі ці функції, розробникам доведеться користуватись або is-функцією, або ассертором, або парсером, залежно від зручності в конкретній точці коду. Саме таким чином і досягається те «змушення» до використання тільки передбачених вами API та самодокументація цих API, про що я писав в пункті висновків № 7).

Але в цього прикладу є суттєвий недолік. Наступні виклики вважаються коректними:

declare const projectId: Uuid;
declare const userId: Uuid;

deleteProject(userId); // expected error
deleteUser(projectId); // expected error

Зрозуміло, що можливість вище — не бажана, і хочеться заборонити розробникам робити таке. Для цього треба створити три branded типа: ProjectId, UserId та ProductId. Це може здаватись контрінтуїтивним на перший погляд, бо де-факто ці всі три типи структурно однакові.

Однак тут треба зробити крок назад і зрозуміти: те, що всі 3 сутності фактично являють собою одне і те саме, не є підставою оголошувати їх як одну сутність. Іншими словами, хоча формально у визначенні сказано, що брендовані типи — це в першу чергу про структуру, не треба забувати, що вони ще дають право створювати вам номінальні типи. І цим треба користуватись в таких прикладах. Головне, чого не треба робити в таких випадках — це в is-функціях не робити запити/інші асинхронні операції з метою зʼясувати, чи належить цей id реальному обʼєкту з БД.

Наш приклад можна переписати наступним чином:

const projectIdBrand = createBrandedType('ProjectId', (probablyProjectId: string) => isUuid4(probablyProjectId));
const userIdBrand = createBrandedType('UserId', (probablyUserId: string) => isUuid4(probablyUserId));
const productIdBrand = createBrandedType('ProductId', (probablyProductId: string) => isUuid4(probablyProductId));

type ProjectId = typeof projectIdBrand.ProjectIdCanonical;
type UserId = typeof userIdBrand.UserIdCanonical;
type ProductId = typeof productIdBrand.ProductIdCanonical;

type Project = {
 id: ProjectId;
 title: string;
}

type User = {
 id: UserId;
 email: string;
}

type Product = {
 id: ProductId;
 price: number;
}

declare function getProject(projectId: ProjectId): Promise<Project>;
declare function deleteProject(projectId: ProjectId): Promise<Project>;
declare function getUser(userId: UserId): Promise<User>;
declare function deleteUser(userId: UserId): Promise<User>;
declare function getProduct(productId: ProductId): Promise<Product>;
declare function deleteProduct(productId: ProductId): Promise<Product>;

declare const projectId: ProjectId;
declare const userId: UserId;

deleteProject(userId); // error as expected
deleteUser(projectId); // error as expected

Тепер ці два виклики є помилковими, як ми того і бажали.

Цей приклад вже достатньо добрий. Якщо сильно не подобається те, що в projectIdBrand, userIdBrand та productIdBrand однакові is-функції (хоча в цьому концептуально проблем нема), можна використати is-функцію типу Uuid. Це дозволить максимально перевикористати код і бути справді чесним:

const projectIdBrand = createBrandedType('ProjectId', uuidBrand.isUuid);
const userIdBrand = createBrandedType('UserId', uuidBrand.isUuid);
const productIdBrand = createBrandedType('ProductId', uuidBrand.isUuid);

Цей приклад звісно не є обовʼязковим — ми могли зупинитись на минулому, без використання додаткової сутності Uuid. Питання того, чи використовувати Uuid як проміжну сутність, залежить від того, чи треба він вам у проєкті в інших місцях як окрема сутність. Якщо ні — то минулого прикладу достатньо. Головне, що показує цей приклад : брендовані типи можуть знаходитись у різних відношеннях один до одного, та на основі одних таврованих типів можна отримувати інші.

Відношення між брендованими типами

Все, що було до цього моменту — це вершина айсберга. Її достатньо для вирішення більшості проблем брендованих типів. Далі в статті ми йдемо глибше та поговоримо про більш нетривіальні властивості та use case-и таврованих типів.

Отже, останній приклад нам показав, що брендовані типи можуть знаходитись у відношенні один до одного. Давайте в останньому прикладі спробуємо подивитись, як насправді виводяться типи ProjectId, ProductId та UserId (далі все будемо розглядати лише на прикладі ProjectId, щоб тричі не повторювати себе):

Бачимо, що в якості TPrimitive береться все одно string, а не Uuid. Це тому, що в нашій реалізації createBrandedType TPrimitive виводиться з типу аргументу is-функції, а в uuidBrand.isUuid тип її аргументу саме string. На практиці це означає, що для виклику, наприклад, getProject на певному Uuid достатньо лише однієї перевірки:

const someUuid = 'uuid12345';

if (projectIdBrand.isProjectId(someUuid)) {
 getProject(someUuid); // ok
}

if(uuidBrand.isUuid(someUuid)) {
 getProject(someUuid); //error as expected
}

Нам не треба одночасно перевіряти на те, що someUuid відповідає і uuidBrand і projectIdBrand. Але якби ми трохи змінили оголошення projectIdBrand:

const projectIdBrand = createBrandedType('ProjectId',
 (probablyUserId: Uuid) => uuidBrand.isUuid(probablyUserId));

То приклад вище перестане працювати, і щоб викликати getProject треба робити одночасно перевірку і на Uuid і на ProjectId, при чому саме в такому порядку:

const someUuid = 'uuid12345';

if (projectIdBrand.isProjectId(someUuid)) {
 getProject(someUuid); // now it is not enough
}

if (uuidBrand.isUuid(someUuid)) {
 getProject(someUuid); //error as expected
}

if (uuidBrand.isUuid(someUuid) && projectIdBrand.isProjectId(someUuid)) {
 getProject(someUuid); // both checks must be met
}

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

Природні брендовані типи

Один з неочевидних пунктів про брендовані типи — вони в вашому проєкті можуть виникати природним чином, навіть якщо ви їх такими не називаєте і не усвідомлюєте, що маєте з ними справу. Для того щоб це зрозуміти, розглянемо приклад системи, де у вас є enum-обʼєкт Route, в якому перелічені всі можливі шляхи вашого застосунку:

enum Route {
 HOME = '/home',
 LOGIN = '/login',
 PROJECT_SELECT = '/projects',
 PROJECT_DETAILS = '/projects/:projectId',
 EDIT_PROJECT = '/projects/:projectId/edit',
 USER_PAGE = '/projects/:projectId/:userId',
 USER_SETTINGS = '/projects/:projectId/:userId/settings',
}

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

Як правило, якщо ви працюєте з фреймворком, то він вже вам запропонує вбудовану в нього функцію navigate(to: string) або її аналог. Єдиний недолік в тому, що ця функція нічого не знає про вашу систему маршрутів, і запросто може вас відправити на неіснуючий раут. Функція navigate, яку ми хочемо створити, буде обгорткою над функцією navigate з бібліотеки:

// Given onto us by the Framework
declare function navigateFromLib(to: string): void;

function navigate(to: Route) {
 navigateFromLib(to);
}

В чому очевидний недолік цієї реалізації — можна зробити наступний виклик:

enum Route {
 HOME = '/home',
 LOGIN = '/login',
 PROJECT_SELECT = '/projects',
 PROJECT_DETAILS = '/projects/:projectId',
 EDIT_PROJECT = '/projects/:projectId/edit',
 USER_PAGE = '/projects/:projectId/:userId',
 USER_SETTINGS = '/projects/:projectId/:userId/settings',
}

navigate(Route.USER_SETTINGS);

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

Щоб обійти цю проблему, ми маємо, звісно, робити навігацію не на сутність типу Route, а на сутність, яка являє собою «підставлений» (substituted) route:

function navigate(toSubstitutedRoute: string) {
 navigateFromLib(toSubstitutedRoute);
}

А що таке substituded route, буде визначити окрема функція-хелпер substitudeRoute:

function substitudeRoute(route: Route, substitutionParams: Record<string, string>): string {
 const substitutedRoute = route.replace(/:(\w+)/g, (_, key) => substitutionParams[key] || '')

 return substitutedRoute;
}

const substitudedRoute = substitudeRoute(Route.PROJECT_DETAILS, {projectId: '12345'});
//    expected result = /projects/12345
const substitudedRoute2 = substitudeRoute(Route.HOME, {});
//    expected result = /home

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

Використовуючи цю функцію, нашим navigate безпечно можна користуватись так:

navigate(substitutedRoute);
navigate(substitutedRoute2);

Але оскільки navigate тепер приймає просто string, то ми все одно можемо написати так:

navigate('whatever-non-existing-thing');

І не буде ніякої помилки на рівні типів, бо аргумент це довільний рядок.

Вихід наступний: сказати, що наша функція вертає не просто рядок, а брендований тип SubstitutedRoute:

type SubstitutedRoute = Branded<string, 'SubstitudedRoute'>;

function navigate(toSubstitutedRoute: SubstitutedRoute) {
 navigateFromLib(toSubstitutedRoute);
}

function substituteRoute(route: Route, substitutionParams: Record<string, string>): SubstitutedRoute {
 const substitutedRoute = route.replace(/:(\w+)/g, (_, key) => substitutionParams[key] || '')

 return substitutedRoute as SubstitutedRoute;
}

const substitutedRoute = substituteRoute(Route.PROJECT_DETAILS, {projectId: '12345'});
//    expected result = /projects/12345

navigate(substitutedRoute);
navigate('whatever-non-existing-thing'); // error
navigate(Route.HOME); // error
navigate(Route.USER_SETTINGS); // error

Тепер navigate очікує аргумент типу SubstitutedRoute, який оголошено як Branded<string>. Своєю чергою функція substitudeRoute вертає не довільний рядок, а SubstitutedRoute.

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

Але цей приклад цінний ще тому, що в ньому ми використовуємо вперше брендований тип не так, як ми до цього його визначали. Раніше ми казали, що брендований тип не може існувати в вакуумі — для нього має бути визначена is-функція, яка буде казати, що це за брендований тип. Але якщо задуматись, то для SubstitutedRoute неможливо написати is-функцію: isSubstitudedRoute(/home/123). Як зрозуміти, що це SubstitudedRoute, а не просто Route? Ніяк. Якщо це не так очевидно, то ця проблема еквівалентна наступній: чи можна написати алгоритм isSumOfEvenNumbers(number), який перевіряє, чи є number результатом суми двох парних чисел. Зрозуміло, що число 12 може бути отримане як 4 + 8, а може і як 11 + 1. Так само і /home/123 може бути просто статичним маршрутом, а може бути результатом підстановки 123 в /home/:number.

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

Згідно з визначенням, тип вважається брендованим, якщо він являє собою:

  • примітив;
  • з унікальною структурою, яку складно або неможливо виразити через засоби мови програмування TypeScript;
  • та для нього можна визначити чисту функцію-ідентифікатор isBrandedType, яка містить в собі інформацію про цю унікальну структуру.

Подивімось, яким з цих трьох пунктів відповідає наш новий брендований тип:

а) він примітивний ✅

б) він має унікальну структуру, яку не те, що складно — її неможливо виразити засобами TS ✅

в) але для нього відсутня is-функція (та відповідно неможливо створити parser та асертор) ❌

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

У SubstitutedRoute немає is-функції, але є функція substituteRoute — яка його просто генерує на основі примітив. І ця функція є єдиним «джерелом» цього брендованого типу. По-іншому його ніяк не отримати в коді. Тому для нього я пропоную назву природній брендований тип.

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

Таким чином, природні та канонічні брендовані типи мають 2 спільні властивості:

а) вони примітивні ✅

б) мають унікальну структуру, яку складно або неможливо виразити засобами TS ✅

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

Отже, давайте дамо таблицю з двома визначеннями для цих двох типів:

Брендований тип I-го типу

Брендований тип II-го типу

Визначення

брендований тип Branded<T, B> є І-го типу, якщо він є таким примітивним типом даних, runtime структуру якого неможливо або дуже складно передати через type-level синтаксис TypeScript. Тому щоб розкрити, що це саме за runtime структура даних, використовується додаткова runtime чиста is-функція-утиліта, яка і містить інформацію про цю структуру. А на рівні типів до примітивного типу додається унікальна type-level only властивість з унікальним значенням, як ідентифікатор брендованого типу.

брендований тип Branded<T, B> є ІI-го типу, якщо він є таким примітивним типом даних, який є результатом певної однозначно незвортної операції. Ця операція відтворюється функцією-генератором, яка створюється разом з брендованим типом ІІ-го типу. На рівні типів до примітивного типу додається унікальна type-level only властивість з унікальним значенням, як ідентифікатор брендованого типу.

Use Case

Творення самодокументованого коду, підвищення типобезпеки та enforcement на використання лише тих функцій і методів, які передбачив розробник брендованого типу.

Семантика використання

Акцент робиться на структурі runtime значення

Акцент робиться на операції, результатом якої є брендований тип

Аналізуючи визначення брендованого типу ІІ-го порядку можна побачити, що я не включив у визначення вимогу на унікальну runtime структуру. Чому так?

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

Повертаючись до прикладу з числом 12, воно є брендованим типом SumOfEvenNumbers, якщо воно є результатом операції 4 + 8, але те саме число 12 не буде брендованим типом SumOfEvenNumbers, якщо воно є результатом операції 11 + 1. Тобто унікальна структура для нього необовʼязкова. Так, у нашому прикладі з SubstitutedRoute — так ще співпало, що він має й унікальну структуру. Як в цьому розібратись та не плутатись?

Строго кажучи, те, що ми назвали в тому прикладі Route та SubstitutedRoute, варто називати AppRoute та SubstitutedAppRoute. Бо просто терміном Route треба назвати саме брендований тип першого типу, який перевіряє, що рядок possiblyRoute є раутом (відповідає тому, як ми оформлюємо шляхи — є слеші, правильний набір символів і так далі). При такому підході ми бачимо, що SubstitudedAppRoute та і просто AppRoute будуть «підмножинами» типу Route, бо все, що є AppRoute та SubstitudedRoute буде і Route, але не навпаки.

З прикладом з числом 12 насправді можна також сказати те ж саме: якщо ввести брендований тип EvenNumber, то 12 буде EvenNumber. Тобто в цьому сенсі число «12» має також унікальну структуру. Але найзагальнішому випадку брендований тип II-го типу не має мати унікальну структуру і відповідно не є «нащадком» брендованого типу І-го типу.

Приклад: UserMessage. Користувач може в input ввести будь-що завгодно — структури унікальної немає. Унікальність тут лише в тому, що це наше «золото» — його створив користувач і воно цінно саме цим, навіть якщо це пустий рядок.

Отже, які висновки можна зробити про брендовані типи ІІ-го типу:

  1. Наявність унікальної структури необовʼязкова.
  2. Але якщо унікальна структура є, то це свідчить про те, що цей брендований тип ІІ-го типу є ще і представником певного брендованого типу І-го типу.
  3. Якщо функція-генератор брендованого типу приймає в якості своїх аргументів якісь брендовані типи І-го типу та використовує їх для побудови брендованого типу ІІ-го типу, то це не обовʼязково значить, що він також буде представником тих брендованих типів І-го типу, з яких він складається. Достатньо такого контрприкладу, щоб це побачити: SumOfTwoOddNumbers — число 12 є брендованим типом ІІ-го типу SumOfTwoOddNumbers, якщо воно є результатом додавання двох чисел 11 та 1, кожне з яких є представником брендованого типу І-го типу OddNumber, але саме число 12 OddNumber не є.
  4. Приклади брендованого типу ІІ-го порядку: SubstitutedRoute, ParsedJSON, ValidatedPayload, SumOfPositiveNumbers тощо — основна риса в цих всіх прикладах це те, що в назві так чи інакше згадується дія, результатом якої є ці брендовані типи.

Використання Branded для обʼєктів

Ми майже оглянули весь айсберг! Залишилось зовсім трохи. На початку статті було визначено, що не буває брендованих обʼєктів. Якщо казати строго, то їх не буває в сенсі брендованих типів І-го та ІІ-го типу, але, може, існують ще якісь брендовані типи (ІІІ-го типу), про які ми ще поки не знаємо?

Зазвичай, коли кажуть про брендовані обʼєкти, кажуть про, наприклад, такі речі:

type SignUpPayload = Branded<{
 email: string;
 password: string;
}, 'SignUpPayload'>

Здається, приклад валідний. Але насправді, в ньому нема потреби, бо простіше зробити так:

const emailBrand = createBrandedType('Email', (probablyEmail: string) => probablyEmail.includes('@'));
const passwordBrand = createBrandedType('Password', (probablyPassword: string) => probablyPassword.length > 6);

type Email = typeof emailBrand.EmailCanonical;
type Password = typeof passwordBrand.PasswordCanonical;

type SignUpPayload = {
 email: Email;
 password: Password;
}

В цьому прикладі SignUpPayload це звичайний обʼєкт, властивості якого є брендованими типами. Це я і мав на увазі під тим, що на початку статті сформулював як «брендованих обʼєктів не буває і бути не може» — в них просто нема сенсу. Але ж це тільки ми поговорили про брендований тип І-го типу. Чи, може, якщо це брендований тип ІІ-го типу, то тоді там можна використовувати обʼєкти? Давайте розглянемо приклад:

type SignUpPayload = Branded<{
 email: Email;
 password: Password;
}, 'SignUpPayload'>

function validateSignUpCredentials(email: Email, password: Password): SignUpPayload {
 return {
   email,
   password
 } as SignUpPayload
}

Наче нічого кримінального в цьому прикладі немає, але постає логічне питання: в чому сенс функції validateSignUpCredentials? Вона нічого суттєвого фактично не робить. Якщо забрати в SignUpPayload визначення через Branded, нічого не погіршиться, але навпаки код покращиться, бо не треба робити assertion:

type SignUpPayload = {
 email: Email;
 password: Password;
}

function validateSignUpCredentials(email: Email, password: Password): SignUpPayload {
 return {
   email,
   password
 }
}

Підсумуємо: формально ви можете робити брендовані обʼєкті в сенсі І-го і ІІ-го типу, але на практиці в цьому немає практичної користі, бо це використання інструменту не за призначенням. У випадку брендованого типу І-го типу — у вас буде надскладна і не «атомарна» is-функція, яку дуже хочеться декомпозувати на «атомарні» брендовані типи; у випадку брендованого типу ІІ-го типу — його використання на обʼєкті просто не додає ніякої вартості.

Це як з діленням на 0 в математиці: формально можна сказати, що a/0 це нескінченність, але просто потім незрозуміло, що з цим визначенням робити, бо в ньому немає практичного застосування. Іншими словами, заборона таврувати обʼєкти — це не табу, просто в цьому немає практичної цінності.

Але все ж таки хочеться щось і для обʼєктів вигадати. Давайте розглянемо наступний приклад: уявимо, що у нас в проєкті використовуються feature-flags, і нехай Feature в нашому проєкті має наступну (спрощену) структуру:

type Feature = {
 name: string;
 config: object;
}

Нас буде цікавити далі лише name, тому config символічно позначили як object — нам він далі цікавий не буде.

Нехай у вас є три фічі в проєкті:

enum FeatureName {
 FEATURE_1 = 'feature1',
 FEATURE_2 = 'feature2',
 FEATURE_3 = 'feature3',
}

function createFeature(name: FeatureName, config = {}): Feature {
 return {
   name,
   config
 }
}

const feature1: Feature = createFeature(FeatureName.FEATURE_1);
const feature2: Feature = createFeature(FeatureName.FEATURE_2);
const feature3: Feature = createFeature(FeatureName.FEATURE_3);

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

const featureMap: Record<FeatureName, Feature> = {
 [FeatureName.FEATURE_1]: feature1,
 [FeatureName.FEATURE_2]: feature2,
 [FeatureName.FEATURE_3]: feature3,
}

Все просто, але я можу випадково наплутати і неправильно замепати фічі:

const featureMap: Record<FeatureName, Feature> = {
 [FeatureName.FEATURE_1]: feature1,
 [FeatureName.FEATURE_2]: feature2,
 [FeatureName.FEATURE_3]: feature2,
}

Тепер feature3 взагалі не використовується і я не бачу ніякої помилки від компілятора. Як це вирішити? Сказати, що name у Feature це не FeatureName, а якийсь тайп параметр TName extends FeatureName:

type Feature<TName extends FeatureName> = {
 name: TName;
 config: object;
}

Тоді createFeature стане generic функцією:

function createFeature<TName extends FeatureName>(name: TName, config = {}): Feature<TName> {
 return {
   name,
   config
 }
}

І тоді нашу карту треба визначати вже не як Record<FeatureName, Feature>, а як певний mapped type:

const featureMap: {
 [N in FeatureName]: Feature<N>;
} = {
 [FeatureName.FEATURE_1]: feature1,
 [FeatureName.FEATURE_2]: feature2,
 [FeatureName.FEATURE_3]: feature2, // error finally!
}

Тоді вже компілятор покаже нам помилку, що Feature<’feature2’> не можна привласнити в Feature<’feature3’>.

Де ж тут брендовані типи? Насправді Feature<TName> і є брендованим типом ІІІ-го типу, просто ми цього ще поки не усвідомили. Як це наочно побачити? В прикладі ми допустили, що Feature у нас має мати властивість name. Але насправді в конкретному SDK Feature Flag manager-у може бути не передбачене те, що то властивість name присутня. Відповідно, якщо немає name, то і приклад наш не працює:

type Feature<TName extends FeatureName> = {
 config: object;
}
…
const featureMap: {
 [N in FeatureName]: Feature<N>;
} = {
 [FeatureName.FEATURE_1]: feature1,
 [FeatureName.FEATURE_2]: feature2,
 [FeatureName.FEATURE_3]: feature2, // no error anymore!
}

Помилки більше нема, бо всі Feature структурно однакові. Що робити, коли властивості фактично немає, але хочеться, щоб вона була? Використати Branded типи для додавання віртуальної або мнимої властивості:

type Feature<TName extends FeatureName> = Branded<{
 config: object;
}, `Feature-${TName}`>;

Тепер більше name ніякої немає, а в її ролі грає назва брендованого типу. Тоді наша createFeature більше не має повертати name:

function createFeature<TName extends FeatureName>(name: TName, config = {}): Feature<TName> {
 return {
   config
 } as Feature<TName>
}

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

const featureMap: {
 [N in FeatureName]: Feature<N>;
} = {
 [FeatureName.FEATURE_1]: feature1,
 [FeatureName.FEATURE_2]: feature2,
 [FeatureName.FEATURE_3]: feature2, // now errors again!
}

Feature<TName> є брендованим типом ІІІ-го типу незалежно від того, чи використовуємо ми тип Branded для його створення, чи ні. Брендованими їх можна вважати через те, що для цього типу виконується вимога номінальної типізації: Feature<N1> не можна привласнити в Feature<N2> та навпаки — в них є властивість name (реальна чи віртуальна), яка виконує роль ідентифікатора типа. Якщо ця властивість справді є в Runtime, то будемо називати це реальним брендованим типом ІІІ-го типу, а якщо вона є віртуальною та чіпляється через Branded, то є атрибутивним брендованим типом ІІІ-го типу.

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

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

Отже, давайте дамо йому визначення: тип SomeType<N> є брендованим типом ІІІ-го типу, якщо він у своєму визначенні має певну властивість [property] типу його type-параметра N.

В цьому визначенні ніяк не фігурує вимога на використання Branded<T, B> type-утіліти. Просто наявність властивості, тип якої визначається тайп-параметром, вже робить його брендованим.

Use Case таких типів: використовуються в mapped типах для реалізації співставлення 1 до 1-го.

Висновки

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

Зокрема ми:

  1. Розглянули проблему брендованого типу.
  2. Розглянули те, за що критикують брендовані типи — незрозуміле value та погіршення якості коду через постійне використання as приведень.
  3. Зрозуміли, чому ці елементи критики не мають під собою жодної підстави, а є результатом неправильного використання Branded-типів; бо брендовані типи є лише інструментом мови програмування, а будь-які інструменти самі по собі не є ані поганими, ані добрими — ними лише можна користуватись правильно та отримувати value й гарний код, або користуватись неправильно і отримувати сумнівне value та код.
  4. Розглянули, як зʼявився перший на світі брендований тип.
  5. На основі цієї історії дали перше визначення брендованому типу: примітивний тип даних з характерною runtime структурою, яку дуже складно або неможливо виразити через засоби мови програмування TypeScript.
  6. Зрозуміли, що для того, щоб позбутись as-hell-у та отримувати користь від брендованих типів, треба для них створювати додаткові функції-утіліти: is-функцію, assertor та parser, які й будуть у своїй реалізації містити інформацію про те, що ж саме за runtime тип це собою являє.
  7. Створили утіліту createBrandedType для уніфікації та спрощення створення брендованих типів згідно з визначенням у пунктах 5 та 6.
  8. Розглянули приклад з ProjectId, UserId та ProductId, на основі якого зрозуміли, що використання брендованих типів при дотриманні вимог пунктів 5 та 6 є type-safe, а також робить код самодокументованим, бо змушує використовувати розробників використовувати саме ті інструменти, які ви для роботи з цими брендованими типами передбачили.
  9. Зрозуміли, що брендовані типи можна комбінувати між собою на прикладі з пункту 8.
  10. Зрозуміли, що брендовані типи бувають різними — на прикладі проєкту з Route ми відкрили брендовані типи ІІ-го типу.
  11. Після цього ті брендовані типи, про які ми говорили в пунктах 5-9, почали називати брендованими типами І-го типу.
  12. Дали визначення брендованим типам ІІ-го типу: брендований тип Branded<T, B> є ІI-го типу, якщо він є таким примітивним типом даних, який є результатом певної однозначно необратимої операції.
  13. Зрозуміли, що для брендованих типів неможливо визначити is-функцію, по аналогії з тим, як неможливо визначити, чи є число 12 результатом сумування 2-х парних чисел. Єдиний спосіб про це дізнатись — це якщо ми знаємо, що число 12 було повернуто спеціальною функцією-генератором sumTwoEvenNumbers, яка для брендованих типів ІІ-типу замінює is-функції.
  14. Зрозуміли, чому лише примітиви має сенс оголошувати як брендовані типи І-го чи ІІ-го типів: в обʼєктах в цьому просто немає практичної користі.
  15. Зрозуміли, як між собою можуть відноситись брендовані типи І-го типу та ІІ-го типу: іноді типи ІІ-го типу можуть бути «підмножиною» І-го типу, але не обовʼязково.
  16. На прикладі з Feature flags зрозуміли, що бувають і брендовані типи ІІІ-го типу — в загальному випадку це такий тип Type<T>, в якого хоча б одна властивість залежить від type-параметру T.
  17. Зрозуміли, що спільного в усіх 3-х типах брендованих типів: номінальна типізація.
  18. Зрозуміли спільний use case в усіх 3-х типах брендованих типів: змушення написання коду в тому стилі і з використанням тих інструментів, які заклав розробник брендованого типу.
  19. Зрозуміли спільне value від використання усіх 3-х типів брендованих типів: type-safety та самодокументований код, що загалом відповідає призначенню мови програмування TypeScript.

Дякую за увагу.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось22
До обраногоВ обраному10
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

Дуже класна стаття! Дякую автору!

Дуже топовий приклад, дякую за статтю. Останні пару років використовував брендовані контракти від zod, здається що досить схожа історія, проте також є перевірки в рантаймі

Дякую за гарну статтю!

Я вже довший час використовую Tagged або Opaque типи на Scala, і виникало питання як подібні речі зробити на TypeScript. І от маю відповідь на моє питання і гарну інструкцію яку дам на вивчення Front End команді. Вже маю прототип тагування типів на Postgres і наступному проекті такий підхід буде буквально усюди: front end, back end, sql.

Ще раз велике дякую за так чудово написану статтю!

В прикладі з substitutedRoute напрошується валідація роут параметрів, як вважаєте?

Дивлячись, що саме ви розумієте під валідацією route параметрів. Буду вдячний, якщо ви розкриєте свою думку парою прикладів

Дякую за таку ретельну і глибоку статтю!
Бо зазвичай переказують документацію для початківців.

На практиці все це в проекті не приживається, чим простіше тим краще

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

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

В моїй практиці використовуєм такий підхід навіть на маленьких проєктах. Хіба такий кусок коду щоб раз запустити подивитись результат і викинути, тоді так чим швидше і коротше тим краще.
А коли маєш багато сутностей і нетривіальний код де наприклад є багато різних ID: project, order, item, etc. то створення окремого типу для кожного ID дуже допомагає: компілятор не дасть переплутати різні типи, плюс «безплатна» документація наприклад в списках параметрів функцій.

Автор чудово це пояснив у статті.
І в більшості випадків це буде тільки 2 додаткові прості стрічки коду на кожен тип (беремо generic з цієї статті там «захована» вся складність а саме використання буде виглядати гарно і просто, можна ще трішки дописати generic щоб використання виходило зовсім лаконічне)

а потім вони кажуть шо с++ складний

Хотів написати «і це кажуть що Java багатослівна», але тут вже ще кращий приклад пригадали)

Чувак придумав собі проблему і потім героїчно її вирішує. Тобто добавляючи 5% type safety (яка і так крута у тайпскрипта), він добавляє 100% складності і −50% читаємості до коду.

Взагалі читабельність і лаконічність коду в будь-якій мові напряму залежить від «статичності типів». І тайпскрипт тут має гарний баланс — даючи потужну статичну типізацію при гарному синтаксисі (якщо не заходити в брєдні як автор). Добавьте мінімальну статичну типізацію в пітон, побачите на що перетвориться код.

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

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