Головна / Статті / ID з брендингом у TypeScript: які формати кодування насправді запобігають неправильному видаленню.

ID з брендингом у TypeScript: які формати кодування насправді запобігають неправильному видаленню.

Шість способів введення UserId та InvoiceId, порівняних у одному тесті: які з них змушують tsc відхилити deleteInvoice(userId), та де Zod забезпечує безпеку під час виконання.

2472 слів

Уявіть собі функцію-допоміжник під назвою deleteInvoice, першим параметром якої має бути ідентифікатор рахунку-фактури, та місце виклику, де замість цього передається ідентифікатор користувача. Якщо tsc завершується з кодом вихіду 0, будь-які типи ідентифікаторів, які ви оголосили, є лише документацією, а документація ніколи не заважала виконанню шкідливих запитів. У цьому посібнику проводиться простий експеримент з використанням шести поширених способів кодування UserId та InvoiceId, показується, які з них змушують компілятор відхилити некоректний виклик, та наводиться практична стратегія щодо того, де робити маркування, де перевіряти на час виконання та як знаходити операції перетворення типів, які тихо скасовують усе це.

Чому два псевдоніми рядків є однаковими за типом

Система типів TypeScript є структурною. Два об’єкти однакової форми є взаємозамінними, а два псевдоніми типу string взагалі не є різними типами — це просто один і той самий string під різними іменами. Механізм перевірки не має нічого, за що можна було б їх розрізнити.

Щоб вирішити цю проблему, використовуються так звані «бренди» типів, які додають фантомну властивість до типу. Ця властивість ніколи не існує під час виконання коду; вона існує лише для того, щоб механізм перевірки розглядав UserId та InvoiceId як різні форми. Бренди на основі перетину типів, бренди з «унікальним символом», префікси шаблонних літералів та метод .brand() у бібліотеці Zod — усе це варіації цього ж прийому.

Два оператори, які часто плутають із брендами типів, включені до механізму порівняння саме через цю плутанину: satisfies та as const. Жоден з них не створює окремого типу.

Пам’ятайте одну важливу річ протягом усього процесу: як тільки TypeScript видалено, кожна з наступних форматувань є звичайним рядком. Node не має жодного уявлення про те, що існували такі типи. Єдина захист, який у вас є, — це те, що перевіряється під час компіляції, а також будь-яка додаткова перевірка під час виконання, яку ви встановите спеціально.

Тест: один некоректний виклик

Кожен форматування стикається з однаковим місцем виклику. Функція очікує значення типу InvoiceId, але надходить значення типу UserId з іншого джерела, і питання полягає у тому, чи буде компілятор заперечувати це.

declare function deleteInvoice(id: InvoiceId): Promise<void>;
const userId = getUserId(); // UserId
await deleteInvoice(userId);

1. Прості псевдоніми типів

Саме з цього починається більшість кодових баз.

type UserId = string;
type InvoiceId = string;

Це компілюється, і неправильний елемент зникає. Оскільки обидва імені відповідають типу string, у перевірника немає підстав для скарг. Це базова проблема, яку решта варіантів намагаються виправити.

2. Літеральні типи з as const

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

const userId = "usr_123" as const;
type InvoiceId = `inv_${string}`;

Це працює лише у вузькому випадку. Якщо userId справді має тип літерала "usr_123", його не можна присвоїти `inv_${string}`, і виклик відхиляється. Але справжні ідентифікатори походять з функцій, запитів та баз даних, і геттер на кшталт getUserId() зазвичай повертає тип string. Як тільки це відбувається, ми повертаємося до варіанту 1. Додавання as const до чогось, що вже має тип string, не перетворює його на щось корисне. Також зазначимо, що тут насправді виконує функцію тип шаблонного літерала, а не as const.

3. відповідає типу string

Ця схема зустрічається під час перегляду коду як захисний захід.

const userId = getUserId() satisfies string;

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

4. Тип перетину

Перетинання типу string з об’єктом, який має поле __brand зі значенням типу readonly, надає кожному ідентифікатору унікальної форми.

type UserId = string & { readonly __brand: "UserId" };
type InvoiceId = string & { readonly __brand: "InvoiceId" };

Тепер tsc відхиляє код deleteInvoice(userId). Це версія, яка працює без жодних бібліотек. Недолік полягає у тому, що «сирі» рядки більше не підходять, тому кожен тип з маркуванням потребує конструктора, який перетворює перевірений рядок на відповідний бренд.

function asUserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) throw new Error("not a user id");
  return raw as UserId;
}

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

5. унікальний символ бренду

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

declare const invoiceBrand: unique symbol;
type InvoiceId = string & { [invoiceBrand]: true };

Компілятор відхиляє неправильний виклик точно так само, як у варіанті 4. Оскільки символ оголошений у одному модулі, іншому файлу стає трохи складніше підробити «бренд», створивши об’єкт того ж типу з таким самим ключем. Компроміс полягає у читабельності: цей паттерн вимагає більше пояснень у pull request, ніж версія з __brand.

6. Бренд Zod

Zod може додати бренд до типу, який він інферує, і, на відміну від усіх попередніх варіантів, також перевіряти значення під час виконання.

const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;

Виклик із UserId від бренду Zod зазнає невдачі під час перевірки типу, а обробка usr_123 за допомогою схеми рахунку-фактури провалюється під час виконання через те, що правило startsWith("inv_") його відхиляє. Саме таку перевірку під час виконання не можуть забезпечити суто статичні методи кодування: значення, яке надійшло з ненадійного джерела, навіть якщо воно вже має неправильну мітку, буде виявлено під час проходження через функцію parse. Ручна конвертація у тип as InvoiceId в іншому місці все одно повністю обходить Zod, тому захист діє лише для значень, які справді проходять через схему.

Оцінка

  • Звичайні псевдоніми: компіляція відбувається, але неправильне значення для видалення проходить далі.
  • as const: компіляція відбувається як тільки значення джерела має тип string.
  • satisfies string: компіляція відбувається.
  • Бренд перетину: відхиляється інструментом tsc.
  • unique symbol бренд: відхилено tsc.
  • Zod бренд: відхилено tsc, а сирий ідентифікатор користувача також відхиляється під час виконання функцією parse.
  • Іншими словами, три варіанти, які люди часто використовують замість введення своїх ідентифікаторів, не допомагають у вирішенні цієї проблеми, тоді як справжні бренди запобігають їй при правильному використанні.

    Відтворення порівняння у власному проекті

    Помістіть шість форматувань у файл на кшталт src/ids.ts, додайте для кожного з них некоректний виклик deleteInvoice(userId) та запустіть компілятор без виведення результату:

    pnpm exec tsc --noEmit
    

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

    InvoiceId.parse(String(userId));
    

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

    Не тестуйте брендування, пишучи id as InvoiceId прямо поруч із визначенням. Операція перетворення завжди компілюється, тож такий тест нічого не доводить.

    Знаходження операцій перетворення, які вже завдають шкоди

    Бренди є настільки сильними, наскільки багато місць їх обходить. Шукайте прямі операції перетворення:

    rg "as InvoiceId|as UserId" src app
    

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

    Що може та не може гарантувати перевірювач

    Бренди є фантомними: генерований JavaScript залишається string. Перевірювач захищає вас у місці виклику лише тоді, коли значення ніколи не проходило через as InvoiceId та ніколи не проходило через функцію, яка приймає звичайний string та повертає бренд без перевірки.

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

    Літеральні типи шаблонів, такі як `inv_${string}`, поводяться дещо схоже на номінальні типи та мають перевагу у документуванні префікса безпосередньо в самому типі. Вони не працюють, коли ідентифікатори є UUID без префікса. Підлаштовуйте тип під дані, а не базу даних під тип.

    Найкращим рішенням на практиці є використання брендів Zod на громадському кордоні та брендів у межах самого додатку. Перевіряйте дані один раз під час їх надходження, наприклад у обробнику запитів, і нехай тип з брендом забезпечує захист усередині додатку. Повторна обробка на кожному кроці між маршрутом, як-от /invoices, та фоновим працівником, лише збільшує витрати. Щоб дізнатися, як централізувати цей кордон, дивіться як захистити кордон Express за допомогою одного мідлвейру Zod.

    Які є витрати на створення брендів

    • Конструктори. Кожному бренду потрібен по одному конструктору. Два типи ID означають дві невеликі функції, а не двадцять.
    • Хибна впевненість. Одина конструкція as InvoiceId, розміщена відразу після JSON.parse, мовчки скасовує захист для всього, що знаходиться далі.
  • Аналіз під час виконання. Zod виконує дві функції — перевірку та надання метаданих, і ви оплачуєте аналіз при вході даних. Це доцільно на рівні входу у систему, але зазвичай є занадто обтяжливим для внутрішніх операцій, коли дані вже були перевірені. На критичних шляхах обов’язково вимірюйте витрати.
  • Перевага. Одна некоректна викликана функція, яка пройшла компіляцію, може призвести до видалення рядка, який неможливо відновити. У порівнянні з цим, невдалий запуск tsc не коштує нічого, і саме в цьому полягає вся користь від цього «фантомного» поля.
  • Реалістична помилка та ефективне рішення

    Розгляньмо інструмент внутрішньої підтримки, де як UserId, так і InvoiceId були оголошені як type X = string. Сторінка, присвятована користувачеві, містить його ідентифікатор у URL, а операція видалення на цій сторінці читає цей ідентифікатор з URL та передає його функції deleteInvoice. Код компілюється, і зникає запис про користувача замість рахунку-фактури.

    Привабливим рішенням є перейменування параметрів для кращої зрозумілості наміру. Однак це не допомагає: наступний недбалий виклик також буде компілюватися без проблем.

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

    type InvoiceId = string & { readonly __brand: "InvoiceId" };
    function asInvoiceId(raw: string): InvoiceId {
      if (!raw.startsWith("inv_")) throw new Error("not an invoice id");
      return raw as InvoiceId;
    }
    

    А на рівні HTTP — схема Zod, яка виконує одночасно верифікацію та брендування:

    const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
    

    Після змін кнопка видалення у рядку рахунку-фактури отримує свій ідентифікатор через asInvoiceId з поля, яке насправді містить ідентифікатор рахунку-фактури. Екран для користувача може зберігати його ідентифікатор у URL, оскільки цей екран присвятований саме користувачеві. Правильний тип даних міг би виявити початковий помічний функціонал; так само як і чіткіша маркування. У команди не було ні того, ні іншого.

    Остання перевірка на коректність: пошук as InvoiceId у вихідному коді має повертати майже нічого, а кожен знайдений елемент має бути обґрунтованим.

    Зробити перевірку повторюваною

    Коротка, повторювана процедура перевірки запобігає тому, що ці результати перетворюються на фольклор. Почніть з запису версій інструментів, адже їхня поведінка може змінюватися між основними випусками. Основною конфігурацією для цього порівняння було невелике додатко для обробки рахунків-фактур з чотирма маршрутами на Node 24, TypeScript 7 та Next.js 16.3; перевірте версії у власній системі перед порівнянням результатів.

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Якщо основна версія відрізняється від очікуваної, зупиніться перед тим, як довіряти подальшим результатам. Потім запустіть додаток та протестуйте відповідні маршрути:

    pnpm exec next dev
    

    Відвідайте /, /invoices, /invoices/1, /settings та знову /invoices, увімкнувши функцію збереження журналу в DevTools, щоб побачити, який ідентифікатор насправді міститься у URL кожного екрана.

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

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    Код вихіду нуль не є доказом того, що продукт правильний. Це означає лише те, що на етапі компіляції не було виявлено жодних проблем, але поведінку програми під час виконання все одно потрібно перевірити. Також корисно записувати по одному рядку для кожної невдалої спроби („Спробовано X, але все одно отримано Y“) разом із версіями та результатами, щоб наступна людина не повторювала цих помилок.

    Поширені способи, якими бренди зазнають невдач

    • as InvoiceId безпосередньо після JSON.parse: у такому випадку бренд перетворюється на формальність.
    • Функція satisfies string, яка приймається під час перевірки, ніби це бренд: вона перевіряє лише відповідність.
    • Використання префіксу „template literal“ у стовпці з UUID, після чого хтось додає цей префікс до збережених даних, щоб тип відповідав вимогам. Це потрібно скасувати; краще надавати брендовані значення після обробки даних, а не змінювати базу даних під певний тип.
  • Конструктор на кшталт asInvoiceId, експортований з файлу barrel, що робить його легкодоступним для будь-якого модуля, який хоче пропустити перевірку.
  • Чек-лист перед викликом функції з ідентифікатором

    • Він не оголошений як type FooId = string.
    • satisfies string не є єдиним критерієм перевірки.
    • Конструктор або механізм парсингу Zod захищає його на рівні входу.
    • deleteInvoice(userId) призводить до помилки у tsc.
    • Результати пошуку за as InvoiceId утворюють короткий список, який можна обґрунтувати.

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

    Компактний набір команд охоплює постійні перевірки, включаючи пошук залишкових псевдонімів ідентифікаторів у вигляді звичайних рядків:

    pnpm exec tsc --noEmit
    rg "as InvoiceId" src
    rg "type \w+Id = string" src
    

    Незаконний виклик deleteInvoice(userId) має знаходитися у файлі тестів, де очікується невдача перевірки типу (наприклад, за допомогою коментаря @ts-expect-error над ним), а не у коді для продакшену, як-от lib/delete.ts.

    Спробуйте це у своєму кодбейсі

    Напишіть незаконний виклик deleteInvoice(userId) поруч із функцією-допоміжником для видалення, яку ви насправді використовуєте, у місці, де перевіряє компілятор. Якщо tsc не видає жодних повідомлень, ваші ідентифікатори є просто коментарями. Перетворіть InvoiceId на тип intersection brand та переконайтеся, що відповідний виклик стане червоним. Додайте конструктор для перевірки чи тип Zod на рівні HTTP-запиту та переконайтеся, що пряме значення „usr_123“ спричиняє помилку. Потім знайдіть використання as InvoiceId та або обґрунтуйте кожен випадок у pull request, або видаліть його.

    Основні висновки

    • Аліаси, as const та satisfies не створюють окремих типів, тому вони не можуть запобігти використанню неправильного ідентифікатора.
    • Типи intersection та unique symbol змушують компілятор відхилити неправильний виклик; типи Zod додають перевірку під час виконання для значень, які проходять через функцію parse.
  • Кожен бренд має вихідний механізм у as. Зберігайте кастування всередині невеликих конструкторів валідації та перевіряйте решту.
  • Аналізуйте та присвоюйте бренд один раз на межі, передавайте статичний бренд всередину та залишайте присвоєння брендів для ідентифікаторів, неправильне використання яких спричиняє незворотну шкоду.