Ідэнтыфікаторы з маркаванням у TypeScript: Калькаванні, якія насправды запобегаюць некоректнаму выдаленню
Шэсць спосабоў вводу UserId і InvoiceId, парабяленых у аднам тэсте: калкі з іх выклікаюць адмову tsc для deleteInvoice(userId), і дзе Zod забезпечвае безпеку пад час выканання.
Уявіце саблей, якой deleteInvoice, чыя першая параметр должна быць ідэнтіфікатором рахунку-фактуры, і месца вызову, якое заместа таго пасвае ідэнтіфікатар пользователя. Якщо tsc завершаецца з кодам выходу 0, то будь-якія ідэнтіфікатары, якія вы заявілі, ўсё роўна являюцься толькі дакументацыяй, а дакументацыя ніколі не запобегала шкодзячым запытам. У гіде адбываецца просты эксперымент з шасцю популярнымі спосабамі кодавання UserId і InvoiceId, показваецца, які з іх прыводзіць кампайлер да адмовы у прыйняттыі некоректнага вызову, і ў канцы даўаецца практычная стратэгія ўсодзе робіць маркірацыю, усодзе перавяряць пад час выканання і як знаходзіць переклады, якія таямніча скасоўваюць усё гэта.
Чаму два аліясы строкі ўсё роўна являюцься тым самым типам
Сістэма типаў TypeScript ёсць структурная. Два аб’ектныя типы з той самай структурой могу быть адна другам заменены, а два аліясы типу string ўжо не являюцца двума разнымі типамі: гэтыя аліясы — це той самы string пад разнымі іменамі. Контролер не мае нічога, чым можна было б іх адзін ад другога разлічыць.
Шаблоны вырашаюць гэтыя проблемы паляганням фантомнай атрыбутаўкі да типу. Гэтая атрыбутаўка ніколі не існуе пад час выканання; яна існуе толькі каб контролер вважаў UserId і InvoiceId разнымі структурамі. Шаблоны перасягу, шаблоны з unique symbol, прэфіксы шаблонаў-літэралаў і метод .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. Калі гэта выйдзе, мы вярнуліся да першага варыянта. Дадзенне 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. Пакалькі символ адзначаны ў аднам модуле, таму іншам файлу стае трохі сложней стварыць падражненне, запісавшы об’ектны тип з той самай ключоўкай. Компрасам — чытаемасць: такі патэран трэба больш адгукнуць у прыемным запите, чым версія з __brand.
6. Zod brand
Zod можа прыўязаць брэнд да типу, які ён выважвае, і, на вярсцю з усіма паказванымі варыянтамі, таксама пераглядаць значэнне пад час выканання.
const InvoiceId = z.string().startsWith("inv_").brand<"InvoiceId">();
type InvoiceId = z.infer<typeof InvoiceId>;
Выклік з UserId маркетплейсу Zod не працюе ў разы з пераглядам типа, а парсінг usr_123 па схеме рачунку не ўдаёцца за час выконання, таму што правіла startsWith("inv_") яго адхіляюць. Самэўсёледзягае перагляд за час выконання — гэта тое, чаго не можа забезпечыць чыста статычная кодавання: значэнне, якое прайшло з ненадзеянага джерага, нават якое ўжо паспамятна пазначана, будзе зафіксавана, калі яно пройдзе через функцыю parse. Ручнае прызначэнне як InvoiceId ў іншым месцы таксама абходзіць Zod, таму захопленне дыяўчыць толькі для значэнняў, якія фактычна пройшлі па схеме.
Рэйтинг
- Простыя аліясы: компілююцца, некоректны выдаленне пройшае.
as const: компілююцца як толькі значэнне выхіднага джерага будзе пазначана якstring.satisfies string: компілююцца.- Маркетплейс перасягу: адхіляецца пры помочы
tsc.
unique symbol брэнд: адхоўваны tsc.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, таямніча абэцяе захаванне для всіх наступных элементаў.
tsc не ёсць жадных расходаў, і гэта ўсё значэнне «фантамнага» поля.Рэалістычны працоўні кейс і эфектывае рашэння
Разглянем внутрэній інструмент падтрымкі, дзе як UserId, так і InvoiceId былі адзначаны як type X = string. Адзін з экранаў, які паказвае інфармацыю пра пользователя, мае ID пользователя ў сваёй URL, а функцыя для выдалення на гэтым экране чытае гэты ID з 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">();
Пасля змены кнопка вычысцення ў рэядку счытніка атрыбут id атрымлець через 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 $?
Код выхідныя значэння 0 не ўтварае доказу таго, што продукт правільны. Ён проста означае, што на этапе компіляцыі не было знайдзена нічога, і яшчэ трэба пераканацца ў правільнай роботе пад час адклэювання. Таксама корыстна пісаць по аднай лініі на кожную невялікую спробу ("спробавана X, але все равно пазірана Y") разам з версіямі та рэзультатамі, ўбліжчы да таго, каб наступны чалавек не павтарыў тых жа помилак.
Пашчэрпаныя спосабы, якімі брэнды праграюць
as InvoiceIdбезпосередна пасляJSON.parse: брэнд стае проста формальнасцю.satisfies stringпрымаецца пад час перагляду, як будзь-які брэнд: ён пераканаецца толькі у адпаведнасці.- У столбцы UUID є прэфікс у виглядзе шаблона, а пазней хтось дадаў гэты прэфікс да збераганых дадзенняў, каб тип паспадзяваў. Хаця бы адмёніць гэта. Краща прызначыць брэнд для перакананага значэння, а не змянюваць базу дадзеных пад конкрэтны тип.
asInvoiceId, вывезеный з файла barrel, які робяць яго простаў доступным для будзь-каго модуля, які хочаць абы праскочыць пераканальванне.Спіс пераконтрацоў пры вызыве ідэнтыфікатора
- Яго не адзначана як
type FooId = string. satisfies stringне ёсць яго едыным крэтам.- Канстрактар або прасэсавацель Zod захоўвае яго на граніцы.
deleteInvoice(userId)выклікае адмовуtsc.- Рэзультаты пошуку
as InvoiceIdствараюць короткі список, які можна падтрымаць.
Таксонамія таксама мае значэнне. Не трэба прызначаць атрыбуты кожнаму стрынгу ў рэпазітарыі. Прызначайце атрыбуты тым ідэнтыфікаторам, якія могу знішчыць або адкрыць даны: шляхі для выдалення, вярнення грошаў і падрабнення ёсць хорашымі першымі кандыдатамі. Якщо у вас будзе пяцьдзiesць такіх атрыбутаў, вы проста дэкоруеце, а не захоўваеце.
Компактны ўзелкі каманд забягаюць за стацыонарнымі перакрытчамі, укладаючы ў сябе пошук за засталымі аліясамі ідэнтыфікатораў у формате звычайных страк:
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, і пераканайцеся, што такая паляўка стала чырвонай. Дадзіце канстрактар для пераканання аб правільнасці дадзеных або викорыстаўце клас Zod на рубежы HTTP-з’явоў і пераканайцеся, што простае значэнне „usr_123“ вызывае памылку. Пасля таго ашукаце выраз as InvoiceId і або падтрымайце кожны такі выпадак у прыемнай заявке, або выдаліце яго.
Ключовыя выводы
- Аліясы,
as constіsatisfiesне ствараюць адзінаковых типоў, таму яны не можаць запобiec викорыстанню неправільных ідэнтыфікатораў. - Тыпы класу intersection і
unique symbolзмушваюць перакладчык адхіліць неправільныя паляўкі; класы Zod дадаюць перакананне пад час выконання для значэнняў, якія праходзяць через функцыюparse.
as. Размешчайте касты ўнутрь маленькіх констрактараў для верыфікацыі і аудытуавайте рэшту.