В прошлом квартале мы допустили баг, из-за которого вернули деньги не тому клиенту. Функция processRefund получила идентификатор пользователя там, где ожидала идентификатор заказа. Строки выглядели одинаково, тесты прошли, и TypeScript не высказал ни единого возражения.

Оба идентификатора были типизированы как string. TypeScript не видит между ними разницы. Если вы когда-либо передавали userId в параметр, который ожидает orderId, и видели, как компилятор пожимает плечами, вы уперлись в ту же стену.

Псевдонимы типов это не исправляют. Как и тщательное именование переменных. Компилятор делает именно то, что вы ему сказали, — и это проблема.

Почему type UserId = string — это ложь

TypeScript использует структурную типизацию. Два типа совместимы, если их структуры совпадают. Когда вы пишете type UserId = string, вы не создаёте новый тип. Вы создаёте псевдоним. На этапе компиляции UserId и string идентичны. Как и UserId и OrderId.

type UserId = string;
type OrderId = string;

function fetchOrder(id: OrderId) {
  // ...
}

const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.

Компилятор отбрасывает псевдонимы типов во время проверки типов. Имя UserId нужно людям. Проверщик типов игнорирует его. Обычно это фича. Она позволяет менять реализации без церемоний. Но для идентификаторов, которые никогда не должны быть взаимозаменяемыми, это самострел.

Брендированные типы делают идентичные структуры несовместимыми

Решение — брендированный тип, иногда называемый opaque type или newtype. Вы пересекаете базовый тип с уникальным брендом, который существует только на уровне типов.

Во время выполнения значение всё ещё просто строка. На этапе компиляции бренд делает его отличным от любой другой строки.

Вот паттерн с использованием unique symbol:

declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };

unique symbol гарантирует, что никакой другой тип не сможет случайно разделить этот бренд. Свойство readonly означает, что вы не можете его мутировать. Во время выполнения свойство-символ не существует на самой строке, так что это чисто конструкция времени компиляции.

Чтобы создать значение, нужна конструктор-функция, которая приводит сырую строку:

function UserId(value: string): UserId {
  return value as UserId;
}

function OrderId(value: string): OrderId {
  return value as OrderId;
}

Теперь прежний баг ловится ещё до запуска кода:

const uid = UserId("usr_0192");
fetchOrder(uid);
//     ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

Сообщение об ошибке понятное. Типы структурно разные, потому что их бренды отличаются. Компилятор не даст вам их перепутать.

Шаблонный код раздражает. Вот помощник.

Писать бренд и конструктор для каждого типа ID надоедает быстро. Мы используем маленькую утилиту, которая генерирует и то, и другое:

interface Brand<T> {
  readonly __brand: T;
}

type Branded<T, B> = T & Brand<B>;

function makeBrand<T, B>(
  _brand: B
): (value: T) => Branded<T, B> {
  return (value) => value as Branded<T, B>;
}

Использование:

type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");

type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");

Это сокращает объявление до двух строк на идентификатор. Вы также можете использовать подход с unique symbol внутри дженерик-хелпера, если хотите более сильную защиту от коллизий. В любом случае цель одна: сделать стоимость добавления нового брендированного типа достаточно низкой, чтобы вы действительно это делали.

А как насчёт объектов и чисел?

Брендированные типы работают для любого примитива, не только строк. Числовые ID мы брендируем так же:

type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");

Вы также можете брендировать объекты. Если у вас есть две конфигурации с одинаковой формой, которые никогда не должны меняться местами, забрендируйте их:

type ApiConfig = Branded<{
  endpoint: string;
  timeout: number;
}, "ApiConfig">;

Но будьте осторожны с брендированием объектов. Объект во время выполнения не будет иметь свойства бренда, так что JSON.stringify и операции спреда ведут себя нормально. Обычно это то, что нужно. Но если вы делаете глубокие проверки на равенство или передаёте значения в библиотеки, которые инспектируют типы во время выполнения, бренд вам не поможет. Это строго защита времени компиляции.

Компромиссы реальны, но малы

Брендированные типы добавляют трения. Теперь каждый ID требует вызова конструктора. Вы не можете передать сырую строку напрямую в функцию, которая ожидает брендированный тип. В этом и смысл, но это означает больше кода.

Сериализация — ещё один подводный камень. Когда вы вызываете JSON.stringify для брендированной строки, вы получаете сырую строку обратно. Когда позже парсите её, бренд потерян. Нужно повторно применять конструктор на границах системы. Обычно мы делаем это в парсерах ответов API и мапперах строк базы данных.

const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));

Брендированные типы также не помогают с валидацией во время выполнения. Если строка некорректна, бренд это не поймает. Вам всё ещё нужны zod, valibot или ручная валидация на границах системы. Бренд гарантирует различие типов, а не корректность данных.

Когда это стоит делать, а когда — шум

Мы брендируем идентификаторы, которые пересекают границы модулей: user IDs, organization IDs, trace IDs, span IDs. Это значения, которые проходят через больше всего кода и с наибольшей вероятностью могут быть переданы в неправильном порядке.

Мы не брендируем внутренние счётчики циклов, локальные временные переменные или что-либо со scope меньше функции. Накладные расходы не стоят того для значений, которые никогда не покидают место своего рождения.

Если в вашей кодовой базе есть сигнатура функции с тремя подряд идущими строковыми параметрами, это сильный сигнал. Брендированные типы превращают этот вызов из угадывания в то, что компилятор проверит за вас.

Один практичный пункт старта

Вам не нужно завтра брендировать каждую строку в кодовой базе. Выберите идентификаторы, которые вызывали реальные баги. Добавьте бренды для них. Наблюдайте, как компилятор ловит перепутывание во время следующего рефакторинга. Именно в тот момент, когда ошибка сборки предотвращает продакшен-баг, дополнительные вызовы конструкторов начинают казаться дешёвыми.

Начните с одной границы модуля. Добавьте бренд UserId. Добавьте бренд OrderId. Обновите слой базы данных, чтобы применять конструкторы, когда приходят строки. Посмотрите, как это ощущается. Если это поймает один неправильный аргумент на код-ревью, оно окупится.