Branded types и номинальная типизация

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

type UserId = string;
type OrderId = string;

const user: UserId = "123";
const order: OrderId = user; // допустимо, хотя семантически ошибка

Такая модель удобна, но становится источником скрытых ошибок в доменных моделях. Особенно это проявляется в идентификаторах, денежных значениях, токенах, email-строках и любых примитивах с разной семантикой.

Номинальная типизация решает эту проблему: тип считается совместимым только если он явно объявлен как таковой, независимо от структуры. В TypeScript её можно имитировать через брендинг (branding).


Брендированные типы как механизм номинальности

Идея брендинга заключается в добавлении «фиктивного» поля, которое существует только на уровне типов. Это поле не влияет на runtime, но делает тип уникальным для компилятора.

Zod предоставляет встроенную поддержку branded types через API .brand().

import { z } from "zod";

const UserIdSchema = z.string().brand<"UserId">();
const OrderIdSchema = z.string().brand<"OrderId">();

type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;

Теперь даже при одинаковой базовой структуре (string) типы становятся несовместимыми:

const userId: UserId = UserIdSchema.parse("u_123");
const orderId: OrderId = OrderIdSchema.parse("o_456");

const test: UserId = orderId; // ошибка типов

Бренд создаёт номинальную метку, которую TypeScript учитывает при проверке совместимости.


Механика работы brand в Zod

Метод .brand<T>() добавляет phantom property на уровне типов. Он не изменяет runtime-значение, а только расширяет типовую информацию.

В упрощённом виде это эквивалентно:

type Branded<T, B> = T & { readonly __brand: B };

Zod скрывает эту реализацию, но семантика остаётся той же: тип становится пересечением базового значения и уникального идентификатора бренда.


Практическое применение брендинга

Идентификаторы доменных сущностей

Наиболее частый кейс — различение ID разных сущностей.

const UserId = z.string().uuid().brand<"UserId">();
const ProductId = z.string().uuid().brand<"ProductId">();

type UserId = z.infer<typeof UserId>;
type ProductId = z.infer<typeof ProductId>;

Теперь невозможно случайно передать ProductId туда, где ожидается UserId.

function getUser(id: UserId) {}

const productId = ProductId.parse("550e8400-e29b-41d4-a716-446655440000");

getUser(productId); // ошибка

Защита бизнес-инвариантов

Брендированные типы полезны там, где примитивы имеют строгую семантику:

  • email
  • валюты
  • хеши
  • токены доступа
  • координаты в специфической системе
const Email = z.string().email().brand<"Email">();
type Email = z.infer<typeof Email>;

Комбинирование brand с transform и refine

Брендинг часто используется вместе с валидацией и трансформацией.

const NonEmptyString = z.string().min(1);

const UserName = NonEmptyString
  .transform((val) => val.trim())
  .brand<"UserName">();

В этом случае:

  1. выполняется проверка
  2. выполняется трансформация
  3. результат получает уникальный тип

Важно понимать порядок: brand применяется к итоговому типу после всех transform-операций.


Брендированные числа и слабые места

Числовые бренды используются реже, но полезны для доменных единиц:

const Milliseconds = z.number().int().nonnegative().brand<"Milliseconds">();
const Seconds = z.number().int().nonnegative().brand<"Seconds">();

Однако TypeScript не предотвращает арифметические операции между ними:

const a = Milliseconds.parse(1000);
const b = Seconds.parse(5);

const sum = a + b; // допустимо на runtime, но логически ошибка

Брендинг не защищает от runtime-ошибок — он работает только на уровне компиляции.


Интеграция с функциями и API контрактами

Брендированные типы особенно полезны для строгих контрактов функций.

function fetchUser(id: UserId) {
  // ...
}

function createOrder(userId: UserId, productId: ProductId) {
  // ...
}

Это устраняет класс ошибок, связанных с перепутанными аргументами одного типа.


Потеря бренда и как её избежать

Бренд исчезает при небрендированных преобразованиях:

const raw = "123";
const id: UserId = raw; // ошибка

Но он также может быть потерян при небезопасных кастах:

const id = "123" as UserId; // обход системы типов

Такие приведения полностью снимают защиту брендинга и сводят его к нулю.


Взаимодействие с внешними данными

Обычно бренд применяется только после валидации входящих данных:

const UserId = z.string().uuid().brand<"UserId">();

const data = UserId.parse(inputFromApi);

Таким образом гарантируется, что брендированное значение никогда не возникает из «сырых» источников без проверки.


Композиция брендов и расширение типов

Бренды можно комбинировать через пересечение типов:

type Timestamp = z.infer<typeof Milliseconds> & { readonly precision: "ms" };

Хотя это редко требуется, подобная композиция позволяет строить более сложные доменные модели поверх примитивов.


Phantom typing как базовый принцип

Брендированные типы в Zod — частный случай phantom typing: данные несут дополнительную информацию на уровне типов, не влияя на runtime.

Это позволяет:

  • добавлять семантику без изменения данных
  • усиливать контроль типов без потерь производительности
  • моделировать доменную строгость поверх примитивов

Ограничения подхода

Несмотря на удобство, брендинг имеет ограничения:

  • не защищает от runtime-математики и логических ошибок
  • легко обходится через as
  • требует дисциплины при построении схем
  • может усложнять типизацию при чрезмерном использовании

Тем не менее, в доменных моделях с большим количеством идентификаторов и строгими контрактами брендинг остаётся одним из наиболее эффективных инструментов повышения надёжности типов в экосистеме TypeScript и Zod.