TypeScript Branded Types: modelado de dominio en tiempo de compilación
Elimina toda una clase de errores en ejecución enseñando a TypeScript la diferencia entre un UserId y un OrderId, aunque ambos sean strings por dentro.

La mayoría de las bases de código en TypeScript tratan los identificadores de dominio como simples primitivos. Un userId es un string. Un orderId es un string. El amount de una transacción es un number. El sistema de tipos estructural de TypeScript los ve como formas idénticas, así que pasar un orderId donde se espera un userId compila sin problemas — hasta que tu base de datos devuelve "user not found" a las 2 de la madrugada y te pasas una hora rastreando un argumento intercambiado.
Esto es obsesión por lo primitivo, y los branded types eliminan por completo esta clase de problemas en tiempo de compilación, sin ningún costo en tiempo de ejecución.
El compilador no te salva (por defecto)
TypeScript usa tipado estructural: dos tipos son compatibles si comparten la misma forma. Para los primitivos, todo string es compatible con cualquier otro string. Ese es el comportamiento correcto para un sistema de tipos de propósito general, pero deja un vacío entre lo que tu modelo de dominio significa y lo que el verificador de tipos puede realmente exigir.
// ❌ All three arguments are strings — the compiler can't distinguish them
async function transferFunds(
fromAccountId: string,
toAccountId: string,
referenceId: string,
): Promise<void> {
await ledger.debit(fromAccountId, referenceId); // Accidentally used referenceId here
}
// This call compiles without complaint — the arguments are in the wrong order
transferFunds(toId, fromId, accountId);Ninguno de estos errores aparece como error de tipos. Aparecen como comportamiento incorrecto en producción. El sistema de tipos tiene la información necesaria para detectarlos — solo necesita que codifiques la semántica del dominio en los tipos.
Branded types: el patrón central
Un branded type envuelve un primitivo con una etiqueta de tipo fantasma. Esa etiqueta es invisible en tiempo de ejecución — TypeScript la elimina después de verificar los tipos — pero hace que primitivos estructuralmente idénticos resulten incompatibles para el compilador.
// The foundation — a generic brand utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Domain identifiers
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type AccountId = Brand<string, "AccountId">;
// Domain scalars with units
type Cents = Brand<number, "Cents">;
type Percentage = Brand<number, "Percentage">;
type UnixTimestamp = Brand<number, "UnixTimestamp">;La propiedad __brand nunca existe en ningún objeto real. Existe únicamente en el sistema de tipos. En el momento en que la agregas, UserId y OrderId pasan a ser estructuralmente distintos, y TypeScript se niega a aceptar uno donde se espera el otro.
// ✅ Type-safe transfer — wrong ID order is a compile-time error
async function transferFunds(
from: AccountId,
to: AccountId,
amount: Cents,
): Promise<void> {
// ...
}
declare const userId: UserId;
declare const accountId: AccountId;
declare const amount: Cents;
transferFunds(accountId, accountId, amount); // ✅ Compiles
transferFunds(userId, accountId, amount);
// Error: Argument of type 'UserId' is not assignable to parameter of type 'AccountId'.Construcción de valores branded en el límite
La regla es simple: los valores branded se crean en exactamente dos lugares — la validación de entradas HTTP/RPC y el mapeo de las lecturas de la base de datos. En cualquier otro punto del código se reciben valores que ya vienen branded, y nunca se hace un cast.
// Smart constructors — validation and branding happen together
function parseUserId(raw: unknown): UserId | null {
if (typeof raw !== "string" || raw.length < 10 || raw.length > 36) return null;
return raw as UserId; // The only place we use `as` for this type
}
function parseCents(raw: unknown): Cents | null {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return null;
return raw as Cents;
}
// API route handler — brand at the entry point
export async function POST(req: Request) {
const body = await req.json();
const userId = parseUserId(body.userId);
const amount = parseCents(body.amount);
if (!userId || !amount) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
// From here on, all downstream functions receive typed domain values
await paymentService.processPayment(userId, amount);
}Si te encuentras escribiendo as UserId dentro de la lógica de un servicio o
un repositorio, es señal de que el límite no se está respetando. Ese cast
pertenece al constructor o al esquema, no debe estar disperso por la lógica de
negocio.
Integración con Zod
Si ya validas la entrada y salida de datos con Zod, los brands se integran sin fricción mediante .brand(). La validación y el tipado nominal ocurren en una sola definición de esquema.
import { z } from "zod";
const UserIdSchema = z.string().min(10).max(36).brand<"UserId">();
const OrderIdSchema = z.string().uuid().brand<"OrderId">();
const CentsSchema = z.number().int().nonnegative().brand<"Cents">();
// Types are derived from schemas — no duplication
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
type Cents = z.infer<typeof CentsSchema>;
const CreateOrderSchema = z.object({
userId: UserIdSchema,
amount: CentsSchema,
});
// Parsing produces branded types automatically
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error.flatten());
// result.data.userId is UserId, result.data.amount is Cents
await orderService.create(result.data.userId, result.data.amount);El .brand() de Zod usa internamente el mismo mecanismo de tipo fantasma. Este es el enfoque preferido cuando ya trabajas en un código base centrado en Zod — el esquema se convierte en la única fuente de verdad, tanto para la forma como para la identidad de dominio.
Brands numéricos para lógica financiera y temporal
Los identificadores de tipo string reciben la mayor parte de la atención, pero los brands numéricos son donde este patrón evita los errores más dolorosos. Mezclar valores number sin marcar que representan unidades distintas — dólares frente a centavos, porcentajes frente a decimales, segundos frente a milisegundos — es un fallo de corrección silencioso.
// ❌ Ambiguous — is discount 0.15 or 15? The caller has to read the implementation
function applyDiscount(price: number, discount: number): number {
return Math.round(price * (1 - discount));
}
// ✅ The types document and enforce the expected units
function applyDiscount(price: Cents, discount: Percentage): Cents {
return Math.round(price * (1 - discount / 100)) as Cents;
}
const price = 2000 as Cents; // $20.00 in cents
const discount = 15 as Percentage; // 15%, not 0.15
applyDiscount(price, discount); // ✅ Returns 1700 Cents = $17.00
applyDiscount(discount, price); // ❌ Type error: arguments swappedEl Percentage branded también funciona como documentación en línea de que la función espera 15, no 0.15. Esa convención queda impuesta por el tipo, no por un comentario que puede desactualizarse con el tiempo.
Aplicación de brands en la capa de repositorio
Los drivers de base de datos y los ORM devuelven valores string y number sin marcar — al leer desde el almacenamiento necesitas un segundo límite de branding, tan riguroso como el límite HTTP.
// Repository — brand on the way out, every time
async function findUserById(id: UserId): Promise<User | null> {
const row = await db
.selectFrom("users")
.where("id", "=", id) // UserId extends string, so this works
.selectAll()
.executeTakeFirst();
if (!row) return null;
// Apply brands at the mapping layer
return {
id: row.id as UserId,
email: row.email,
createdAt: row.created_at as UnixTimestamp,
};
}El cast sigue estando en un único lugar explícito — el mapeador de filas — y no disperso por todo el código. Cada consumidor de findUserById recibe valores correctamente tipados sin saber de dónde vino el branding.
Cuándo los branded types no son la herramienta adecuada
Los brands funcionan bien para escalares. No sustituyen un modelado más rico.
| Escenario | Mejor enfoque | Por qué |
|---|---|---|
| Validación de formato complejo (email, URL) | Esquema Zod con salida .brand() | La lógica de validación no encaja en un tipo |
| Objetos de dominio con invariantes | Clase con constructor privado | Se necesitan métodos, no solo un primitivo tipado |
| Transiciones de estado de una entidad | Unión discriminada | Múltiples formas, no un solo escalar |
| Enumeraciones | const enum o literal de unión | Conjunto cerrado de valores conocidos |
| Contratos de API entre servicios | Tipos generados desde OpenAPI | La alineación del esquema importa más que el branding |
Abusar de los brands genera una proliferación de casts. Si estás escribiendo as UserId en más de dos o tres lugares de todo el código base para un mismo tipo, el límite de construcción no se está respetando.
Puntos clave
- El tipado estructural hace que los primitivos sean intercambiables por defecto — cualquier
stringpuede reemplazar a cualquier otrostring, sin importar la intención de dominio - Los branded types no cuestan nada en tiempo de ejecución — la propiedad fantasma es una ficción a nivel de tipos; sin overhead ni bytes adicionales
- Limita los casts con
asa dos lugares: los constructores inteligentes en los límites de E/S y los mapeadores de filas del repositorio — en ningún otro sitio - El método
.brand()de Zod convierte el branding basado en esquemas en la opción natural por defecto en proyectos centrados en Zod - Los brands numéricos están infrautilizados —
Cents,PercentageyUnixTimestampevitan errores silenciosos de unidades que son difíciles de rastrear en producción - Los brands no sustituyen un modelado de dominio rico — usa uniones discriminadas para estados, clases para objetos con invariantes; los brands son solo para escalares


