Идентификаторы с брендингом в TypeScript: какие кодировки действительно предотвращают неправильное удаление
Шесть способов ввода UserId и InvoiceId, сравненных в рамках одного теста: какие из них приводят к отклонению запроса deleteInvoice(userId) средством tsc, и где механизмы Zod обеспечивают безопасность во время выполнения.
Представьте помощник под названием 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. Как только это происходит, мы возвращаемся к первому варианту. Добавление ключевого слова as const к уже объявленному как string типу не приводит к его узкой спецификации. Также стоит отметить, что на самом деле здесь выполняет функцию тип шаблонного литерала, а не as const.
3. соответствует типу string
Этот шаблон встречается при ревью кода и представляется как мера безопасности.
const userId = getUserId() satisfies string;
Код компилируется. Оператор satisfies проверяет, соответствует ли выражение определенному типу, сохраняя при этом его собственный выведенный тип; он никогда не вводит новый номинальный тип. Это полезный оператор, ближе к функции проверки орфографии, чем к механизму идентификации типов, и он не обеспечивает защиты от передачи неверного идентификатора.
4. Тип пересечения
Пересечение типа string с объектом, содержащим поле __brand с правилом только для чтения, придает каждому идентификатору уникальную структуру.
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. Ручное преобразование в тип InvoiceId в другом месте также полностью обходит Zod, поэтому защита действует только для значений, которые действительно проходят через схему.
Оценка
- Простые псевдонимы: компиляция проходит, но неправильное удаление данных все равно возможно.
as const: компиляция происходит сразу, как только исходное значение имеет типstring.satisfies string: компиляция проходит.- Интерсекция брендов: отклоняется компилятором
tsc.
unique symbol brand: отклонён 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.
Каковы затраты на брендинг
- Конструкторы. Каждому внутреннему типу требуется по одному конструктору. Два типа идентификаторов означают наличие двух небольших функций, а не двадцати.
- Ложная уверенность. Одна строка
as InvoiceId, размещенная сразу послеJSON.parse, бесшумно отменяет защиту для всего, что находится дальше по потоку данных.
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принимается в процессе проверки как будто это брендинг: он проверяет лишь соответствие формату. - Использование префикса в виде литерала шаблона для столбца 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 в тип интерсекции и убедитесь, что вызов станет красным. Добавьте конструктор для проверки или тип Zod на границе HTTP-запроса и убедитесь, что прямой ввод „usr_123“ вызовет ошибку. Затем найдите выражения вида as InvoiceId и либо обоснуйте каждый такой случай в пул-реквесте, либо удалите их.
Основные выводы
- Псевдонимы,
as constиsatisfiesне создают отдельных типов, поэтому они не могут предотвратить использование неверного идентификатора. - Типы интерсекции и
unique symbolзаставляют компилятор отклонять неверные вызовы; типы Zod добавляют проверку во время выполнения для значений, проходящих через методparse.
as. Храните преобразования внутри небольших конструкторов проверки и аудитируйте остальную часть кода.