Menu

Брендированные типы в TypeScript: номинальная типизация

Брендированный тип это примитив с невидимой меткой, например string & { readonly __brand: "UserId" }, чтобы UserId нельзя было передать туда, где ожидается OrderId. Как работают бренды, функции-конструкторы с проверкой, обобщённый помощник Brand, бренды на unique symbol и брендированные числа.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

TypeScript сравнивает типы по структуре, поэтому два псевдонима string взаимозаменяемы. Брендированный тип добавляет метку, существующую только в системе типов, string & { readonly __brand: "UserId" }, и это делает UserId несовместимым с обычной строкой и со всеми другими брендами:

Во время выполнения userId это просто строка "u_42". Бренд это метка времени компиляции, и её единственная задача не давать перепутать идентификаторы, единицы измерения и проверенные строки.

Проблема: псевдонимы это только имена

Псевдоним типа не создаёт новый тип. Он даёт существующему типу второе имя, и компилятор считает оба имени одним и тем же:

Это структурная типизация: TypeScript проверяет, подходит ли структура, а string подходит к string. У объектов структуры обычно различаются; у идентификаторов, email-адресов, валют и единиц измерения никогда. Бренды исправляют именно этот случай.

Как работает бренд

string & { readonly __brand: "UserId" } это пересечение: значение должно быть строкой и ещё иметь свойство __brand типа "UserId". Ни у одной настоящей строки такого свойства нет, поэтому ни одну обычную строку нельзя ему присвоить:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

Важное направление по-прежнему работает: UserId это строка, поэтому его можно передать во всё, что принимает строку, вызвать на нём .startsWith() или вставить в шаблон. Бренд закрывает только вход.

Функции-конструкторы с проверкой

Утверждение as UserId это единственный вход, а утверждение ничего не проверяет. Поместите его в одну функцию, которая проверяет вход, и тогда о каждом брендированном значении в программе известно, что оно прошло эту проверку:

sendWelcome больше не проверяет вход, потому что тип параметра говорит, что проверка уже была. Это идея «parse, don't validate»: проверить на границе, а затем нести доказательство в типе. Защитник типа тоже годится как конструктор, если вам больше нравится логическое значение, чем исключение: function isEmail(s: string): s is Email.

Обобщённый помощник Brand

Писать пересечение вручную для каждого типа утомительно. Небольшой дженерик делает это один раз:

Брендирование числа работает так же, как брендирование строки. Обратите внимание, что amount / 100 это обычный number: арифметика над брендированным числом даёт результат без бренда, об этом ниже.

Бренды на unique symbol

Строковое имя свойства вроде __brand выглядит как настоящее свойство: userId.__brand проходит проверку типов как "UserId", но во время выполнения равно undefined, а две библиотеки могут выбрать одно и то же имя. Ключ unique symbol решает обе проблемы:

declare const brand: unique symbol объявляет символ, который существует только для проверки типов; ключевое слово declare означает, что для него не генерируется JavaScript. Поскольку символ не экспортируется из своего модуля, код в других файлах даже не может назвать свойство бренда, так что вне этого модуля получить Meters можно только через экспортированные вами функции или утверждение as Meters.

Бренды ничего не стоят во время выполнения

Скомпилированный вывод не содержит следов бренда. Вот строки, сгенерированные для примера с Meters, ниже заголовка модуля, который добавляет компилятор: declare, оба псевдонима типов и каждый as исчезли, а подавленный вызов в последней строке всё равно выполняется.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

Брендированное значение это обычный примитив: typeof даёт "string" или "number", JSON.stringify записывает его как обычно, а сравнения работают как прежде. Обратная сторона в том, что во время выполнения ничего не проверяется, если этого не делает ваша функция-конструктор. Данные, разобранные из JSON, базы данных или URL, приходят как string и становятся UserId, только когда вы пропустите их через эту функцию.

Арифметика и методы теряют бренд

Операции над брендированным значением возвращают базовый тип, потому что бренд не входит в то, что производят + или .slice():

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

Обычно это и нужно: сложение двух сумм в центах даёт центы, а умножение центов на центы нет, и только вы знаете, какие операции сохраняют смысл. Напишите небольшие вспомогательные функции вроде addCents(a: Cents, b: Cents): Cents для операций, которые нужны вашему коду.

Когда использовать брендированные типы

Используйте бренды там, где перепутать два значения одного примитивного типа это реальный риск, а компилятор иначе помочь не может:

СитуацияПримеры брендов
Идентификаторы из разных таблицUserId, OrderId, ProductId
Проверенные строкиEmail, Url, NonEmptyString, Slug
Единицы измерения и валютыMeters, Feet, Cents, Usd, Eur
Очищенный или экранированный текстSafeHtml, SqlIdentifier
Числа с диапазономPercentage, PositiveInt

Не используйте их для значений, которые никогда не путают, и для объектных типов, которые уже различаются по структуре. Библиотеки валидации умеют порождать брендированные типы из схемы: в Zod z.string().brand<"UserId">() даёт схему, у которой parse возвращает брендированный UserId, и функции-конструкторы не нужно писать вручную.

Часто задаваемые вопросы

Что такое брендированные типы в TypeScript?

Приём, который делает несовместимыми два типа с одинаковым представлением во время выполнения. Базовый тип пересекается с меткой, которой нет ни у одного обычного значения: type UserId = string & { readonly __brand: "UserId" }. Тогда обычная строка или OrderId с другой меткой отвергаются там, где ожидается UserId.

Есть ли в TypeScript номинальные типы?

Нет. Система типов TypeScript структурная: два типа с одинаковой структурой взаимозаменяемы, как бы они ни назывались. Объявления классов с членами private или #private ведут себя номинально, а брендированные типы это обычный способ получить тот же эффект для примитивов вроде строк и чисел.

Есть ли у брендированных типов затраты во время выполнения?

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

Как создать значение брендированного типа?

Утверждением типа, в идеале в одной небольшой функции, которая сначала проверяет вход: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Если держать as в этом одном месте, каждый Email в программе прошёл проверку.

Чем псевдоним типа отличается от брендированного типа?

type UserId = string это лишь новое имя: любая строка принимается везде, где ожидается UserId. type UserId = string & { readonly __brand: "UserId" } это новый несовместимый тип: обычная строка сначала должна пройти через функцию-конструктор или утверждение.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ