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<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); // ошибка
Брендированные типы полезны там, где примитивы имеют строгую семантику:
const Email = z.string().email().brand<"Email">();
type Email = z.infer<typeof Email>;
Брендинг часто используется вместе с валидацией и трансформацией.
const NonEmptyString = z.string().min(1);
const UserName = NonEmptyString
.transform((val) => val.trim())
.brand<"UserName">();
В этом случае:
Важно понимать порядок: 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-ошибок — он работает только на уровне компиляции.
Брендированные типы особенно полезны для строгих контрактов функций.
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" };
Хотя это редко требуется, подобная композиция позволяет строить более сложные доменные модели поверх примитивов.
Брендированные типы в Zod — частный случай phantom typing: данные несут дополнительную информацию на уровне типов, не влияя на runtime.
Это позволяет:
Несмотря на удобство, брендинг имеет ограничения:
asТем не менее, в доменных моделях с большим количеством идентификаторов и строгими контрактами брендинг остаётся одним из наиболее эффективных инструментов повышения надёжности типов в экосистеме TypeScript и Zod.