Branded types en TypeScript: que la IA no cruce tus ids
Le pides al agente un endpoint en TypeScript para reembolsar un pedido. Lo escribe en segundos: busca el pedido, comprueba que pertenece al usuario y llama a refundOrder(orderId, userId).
La firma de la función es refundOrder(userId, orderId). Los argumentos van al revés.
tsc no dice nada, los tests en verde. Los dos son string, los dos empiezan por tres letras y un guion bajo, y para TypeScript son exactamente lo mismo. El bug llega a producción y lo encuentra un cliente que no recibe su dinero. Los branded types en TypeScript existen para que ese error no compile.
En corto: un branded type es un string o un number con una etiqueta que solo existe en el sistema de tipos, de modo que UserId y OrderId dejan de ser intercambiables. El compilador rechaza el cruce de argumentos sin coste en runtime. Es una de las defensas más baratas contra un error frecuente en código generado por IA: pasar un valor con la misma forma en la posición equivocada.
Por qué TypeScript acepta un orderId donde va un userId
TypeScript usa tipado estructural: dos tipos son compatibles si tienen la misma forma, da igual cómo se llamen. Un alias como type UserId = string no crea un tipo nuevo, solo le pone un nombre a string.
Este código compila sin un solo aviso:
type UserId = string;
type OrderId = string;
declare function refundOrder(userId: UserId, orderId: OrderId): void;
const userId: UserId = "usr_8f2k";
const orderId: OrderId = "ord_91xa";
refundOrder(orderId, userId); // compila: los dos son string
El nombre del alias da una falsa sensación de seguridad. Lees UserId en la firma y asumes que el compilador lo vigila. No lo hace.
Con código humano el riesgo ya existe. Con código generado es peor: el agente rellena argumentos por posición y por nombre parecido, y nadie relee cada llamada. La disciplina que antes ponía el equipo en el code review ahora tiene que ponerla el compilador.
Es la misma idea que recorre cómo verificar código generado por IA sin leerlo todo: mover comprobaciones de la cabeza del revisor a una herramienta.
¿Qué son los branded types en TypeScript?
Un branded type es un tipo primitivo intersectado con una propiedad fantasma que solo existe en tiempo de compilación, lo que hace que dos valores con la misma forma en runtime sean incompatibles para el compilador.
Es la forma de simular tipado nominal en un lenguaje estructural. En conceptos avanzados de TypeScript aparece en un párrafo; aquí va el detalle que necesitas para usarlo en producción.
Hay dos variantes habituales.
Variante 1: la propiedad __brand
type UserId = string & { __brand: "UserId" };
Funciona y se entiende a la primera. El problema es que __brand parece una propiedad real: sale en el autocompletado del editor y id.__brand compila. En runtime vale undefined, porque nadie la ha asignado nunca. Un agente que ve esa propiedad en las sugerencias puede acabar usándola.
Variante 2: unique symbol
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;
El símbolo se declara pero no se exporta, así que nadie fuera del módulo puede escribir id[brand]. La etiqueta queda invisible en el uso normal. Es la variante más sólida para código compartido.
Cómo crear valores: constructor con validación, no as
Un branded type no se puede asignar desde un string normal. Hay que crearlo, y ese es justo el punto donde se decide si sirve para algo:
export function toUserId(raw: string): UserId {
if (!/^usr_[a-z0-9]+$/.test(raw)) throw new Error(`UserId no válido: ${raw}`);
return raw as UserId;
}
export function toOrderId(raw: string): OrderId {
if (!/^ord_[a-z0-9]+$/.test(raw)) throw new Error(`OrderId no válido: ${raw}`);
return raw as OrderId;
}
const userId = toUserId("usr_8f2k");
const orderId = toOrderId("ord_91xa");
refundOrder(userId, orderId); // ok
refundOrder(orderId, userId); // error
refundOrder("usr_8f2k", orderId); // error: string sin marca
Esto es lo que devuelve tsc --noEmit con TypeScript 7.0.2, copiado tal cual. Las posiciones (línea,columna) son las del archivo completo, que incluye las declaraciones de arriba:
src/branded.ts(23,13): error TS2345: Argument of type 39;OrderId39; is not assignable to parameter of type 39;UserId39;.
Type 39;OrderId39; is not assignable to type 39;{ readonly [brand]: "UserId"; }39;.
Types of property 39;[brand]39; are incompatible.
Type 39;"OrderId"39; is not assignable to type 39;"UserId"39;.
src/branded.ts(24,13): error TS2345: Argument of type 39;string39; is not assignable to parameter of type 39;UserId39;.
Type 39;string39; is not assignable to type 39;{ readonly [brand]: "UserId"; }39;.
Fíjate en el as dentro del constructor. Es el único as legítimo: vive en un sitio, detrás de una validación. El patrón es el de "parse, don't validate" que se explica en programación defensiva en TypeScript: validas una vez en la frontera y el tipo garantiza el resto del recorrido.
El riesgo real es el as repartido por el código. Cuando un agente se encuentra un error de tipos, una de sus salidas favoritas es "usr_8f2k" as UserId en la línea que falla. Compila, y el brand ya no protege nada. Por eso conviene una regla de lint o de revisión: as UserId solo dentro de toUserId.
Zod 4: .brand() con validación incluida
Si ya validas con Zod, no necesitas escribir los constructores a mano. Zod 4 tiene .brand(), que añade la etiqueta al tipo inferido:
import { z } from "zod";
const UserIdSchema = z.string().regex(/^usr_[a-z0-9]+$/).brand<"UserId">();
const OrderIdSchema = z.string().regex(/^ord_[a-z0-9]+$/).brand<"OrderId">();
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
declare function refundOrder(userId: UserId, orderId: OrderId): void;
const userId = UserIdSchema.parse("usr_8f2k");
const orderId = OrderIdSchema.parse("ord_91xa");
refundOrder(userId, orderId); // ok
refundOrder(orderId, userId); // error
El error que da el compilador:
src/zod.ts(15,13): error TS2345: Argument of type 39;string & $brand<"OrderId">39; is not assignable to parameter of type 39;string & $brand<"UserId">39;.
Type 39;string & $brand<"OrderId">39; is not assignable to type 39;$brand<"UserId">39;.
Types of property 39;[$brand]39; are incompatible.
Property 39;UserId39; is missing in type 39;{ OrderId: true; }39; but required in type 39;{ UserId: true; }39;.
La documentación oficial de Zod deja claro que el brand es "a static-only construct": .parse() devuelve el mismo string de siempre.
Por defecto solo se marca el tipo de salida; desde Zod 4.2 un segundo genérico ("in", "out", "inout") cambia esa dirección. La ventaja es que validación y tipo salen del mismo schema.
En el curso de Zod se trabaja este patrón de schemas en la frontera, y en el blog tienes cómo Zod resuelve la validación en runtime.
Segundo caso de uso: céntimos vs euros
Los branded types también separan unidades: un number en euros y otro en céntimos tienen la misma forma y un factor 100 de diferencia, y es el cruce que más dinero cuesta.
type Cents = Brand<number, "Cents">;
type Euros = Brand<number, "Euros">;
const toCents = (euros: Euros): Cents => Math.round(euros * 100) as Cents;
declare function charge(amount: Cents): void;
const price = 19.99 as Euros;
charge(price); // error
charge(toCents(price)); // ok
src/money.ts(12,8): error TS2345: Argument of type 39;Euros39; is not assignable to parameter of type 39;Cents39;.
Type 39;Euros39; is not assignable to type 39;{ readonly [brand]: "Cents"; }39;.
Types of property 39;[brand]39; are incompatible.
Type 39;"Euros"39; is not assignable to type 39;"Cents"39;.
Ojo con la aritmética: toCents(price) + toCents(price) devuelve number, no Cents. El brand se pierde en cada operación, así que necesitas funciones como addCents(a, b): Cents si quieres mantenerlo.
Branded types vs alias vs clase: comparativa
| Criterio | Alias (type UserId = string) |
Branded type (unique symbol) |
Zod .brand() |
Clase con campo #private |
|---|---|---|---|---|
Detecta userId ↔ orderId |
No | Sí | Sí | Sí |
| Coste en runtime | Ninguno | Ninguno | El del .parse() |
Un objeto por valor |
| Serializa a JSON tal cual | Sí | Sí, pero pierde el brand | Sí, pero pierde el brand | Sale como { "value": "…" }; necesitas toJSON para un string |
| Encaja con ORMs y librerías | Directo | Hace falta convertir en la frontera | Hace falta parsear en la frontera | Hay que envolver y desenvolver |
| Validación del valor | No | La que escribas en el constructor | Incluida en el schema | La que escribas en el constructor |
| Limitación / riesgo | Falsa sensación de seguridad | Un as suelto lo anula |
Acopla tus tipos de dominio a Zod | Una clase sin campo privado es estructural también |
La última celda no es teórica. Dos clases UserId y OrderId con solo readonly value: string son intercambiables para TypeScript; compilado con 7.0.2, la asignación pasa sin error. En cuanto añades un miembro privado (un #tag de JavaScript o un private tag de TypeScript), el compilador las separa.
Límites de los branded types
No existen en runtime. Bun ejecuta TypeScript sin comprobar tipos, igual que el type stripping de Node. Si tu CI no ejecuta tsc --noEmit, el brand no protege nada.
JSON los borra. JSON.parse devuelve any, y const back: UserId = copy.id compila sin protestar. Todo lo que entra por red, base de datos o cola tiene que pasar otra vez por toUserId o por el schema.
Fricción con ORMs y librerías. Prisma, Drizzle o tu cliente HTTP devuelven string. O conviertes en la capa de acceso a datos, o acabas con as por todas partes, que es peor que no tener brand.
as lo rompe. Ningún tipo sobrevive a "lo-que-sea" as UserId. Con agentes que escriben cientos de líneas por sesión, esto exige una regla explícita en tu AGENTS.md y en la revisión. Cómo convertir esas reglas en un contrato que se comprueba al revisar está en el método Revisión por Contrato.
Cuesta DX. Mensajes de error más largos, más funciones constructoras y más conversiones. En un script de 200 líneas no compensa. En un dominio con dinero, permisos o varios tipos de id, sí.
Tipado nominal en TypeScript: lo que se discute en la comunidad
El tipado nominal es una de las peticiones más antiguas a TypeScript. El issue #202 del repositorio de TypeScript, "Support some non-structural (nominal) type matching", sigue abierto desde julio de 2014 con casi 500 comentarios (498 en octubre de 2026). Los branded types son el parche de la comunidad mientras tanto.
En el hilo de Hacker News sobre branded types (mayo de 2024, 144 comentarios) hay dos posturas que conviene leer. Un usuario lo resume como "Really ugly way to avoid something already solved by named parameters and/or namespacing".
Otro explica que su equipo prefiere alias simples y asume que la consecuencia es "an extra level of vigilance and discipline in PR reviews, or else implicit trust in one another".
Esa segunda frase es la clave del debate en 2026. La confianza implícita entre compañeros funciona en un equipo pequeño. Con un agente generando código, esa confianza no existe, y la vigilancia en el PR es justo lo que no escala.
Por dónde empezar con branded types en TypeScript
No brandees todo. Empieza por los tipos donde un cruce cuesta dinero o seguridad: ids de entidades distintas que viajan juntos en la misma firma, y cantidades con unidad (céntimos, segundos, milisegundos).
Crea un Brand<T, B> con unique symbol, un constructor validado por tipo y una regla que prohíba as fuera de esos constructores. Añade tsc --noEmit al CI. Y dale esa regla al agente por escrito, no confíes en que la deduzca: es el tipo de contrato que se trabaja en el workshop de SDD + Agentic Engineering.
Los tipos cierran los errores de forma; los de valor los cazan los tests. Para comprobar que esos tests valen, usa mutation testing con Stryker, y para generar las entradas que no se te ocurren, property-based testing con fast-check.
El ejemplo de este post está compilado con TypeScript 7.0.2 y Zod 4.6.5.
Preguntas frecuentes
¿TypeScript tiene tipado nominal?
No. TypeScript es estructural: dos tipos con la misma forma son compatibles. La petición de tipado nominal sigue abierta en el issue #202 desde 2014; mientras tanto, los branded types y las clases con miembros privados son las dos formas de simularlo.
¿Los branded types tienen coste en runtime?
No. La etiqueta solo existe en el sistema de tipos y desaparece al compilar. El único coste es el de la validación que pongas en el constructor, que pagarías igualmente si validas en la frontera.
¿Qué diferencia hay entre __brand y unique symbol?
Con __brand la etiqueta aparece en el autocompletado y id.__brand compila aunque en runtime valga undefined. Con un unique symbol no exportado, nadie fuera del módulo puede acceder a la etiqueta. Para código compartido, unique symbol es más limpio.
¿Sirven los template literal types en lugar de los branded types?
Para ids con prefijo, sí: type UserId = `usr_${string}` rechaza un `ord_${string}` en la misma posición. No sirven para unidades, porque un number en céntimos y otro en euros no tienen prefijo que los distinga.
¿Cómo evito que un agente de IA rompa los branded types con as?
Escribe la regla en tu AGENTS.md (as UserId solo dentro de toUserId) y búscala en la revisión con un grep o una regla de lint. El agente tiende a resolver errores de tipos con as; si no se lo prohíbes explícitamente, lo hará.
¿Zod .brand() valida algo en runtime?
La validación la hacen las reglas del schema, como .regex(), o el propio tipo, como z.uuid(). El .brand() por sí mismo solo cambia el tipo inferido; según la documentación de Zod, no afecta al resultado de .parse().
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
