TypeScript usa tipado estructural: si dos tipos tienen la misma forma, son intercambiables. Eso es potente para la mayoría de los casos —no hace falta declarar que implementas una interfaz si ya tienes los mismos campos— pero crea un punto ciego: dos tipos que representan cosas completamente distintas pero con la misma forma base son, para el compilador, exactamente lo mismo.
El problema: ids que se mezclan sin que el compilador se queje#
En el Team Builder tenemos héroe con id y partidas con id. Ambos son números. Nada impide pasarlos en el orden incorrecto:
// type solo renombra: HeroeId y PartidaId siguen siendo intercambiables.
type HeroeId = number;
type PartidaId = number;
function registrar(heroeId: HeroeId, partidaId: PartidaId): string {
return "heroe=" + heroeId + " partida=" + partidaId;
}
const hId: HeroeId = 76;
const pId: PartidaId = 999;
// TypeScript acepta esto. El resultado es incorrecto, pero no hay error.
registrar(pId, hId);El compilador no protesta porque HeroeId y PartidaId son, a sus ojos, el mismo tipo: ambos son number. El alias solo existe en el código fuente, no en el sistema de tipos.
La solución: intersectar el tipo base con una marca#
La técnica para romper esa equivalencia se llama branding: añadirle al tipo base una propiedad que lo diferencie de cualquier otro tipo con la misma base. La propiedad es imaginaria —existe solo a nivel de tipos— y se elige un nombre improbable para que nadie la produzca por accidente.
// HeroeId es un number que además tiene una marca única.
// La & es la intersección: el valor debe ser asignable a AMBOS lados.
type HeroeId = number & { readonly __brand: "HeroeId" };
// PartidaId tiene su propia marca: misma base, tipo distinto.
type PartidaId = number & { readonly __brand: "PartidaId" };Ahora HeroeId y PartidaId son tipos incompatibles: uno tiene __brand: "HeroeId" y el otro __brand: "PartidaId". Un number suelto no tiene __brand, así que tampoco encaja donde se espera un branded type.
Para crear un valor branded se usa una aserción de tipo (as), la misma que aprendiste en narrowing:
// La aserción le dice al compilador: "sé lo que hago, trátalo como HeroeId".
const hId = 76 as HeroeId;
const pId = 999 as PartidaId;Con los branded types definidos, el compilador ya detecta la confusión:
Smart constructors: un único sitio para el as#
Esparcir as HeroeId por todo el código tiene un problema: cualquiera puede escribirlo sin validar el valor. Si en algún sitio llega un 0 o un -1, la aserción lo brandea igualmente sin protestar.
La solución es un smart constructor: una pequeña función que centraliza la aserción en un único punto y valida el valor antes de brandear. El resto del código solo llama a la función —nunca escribe as directamente.
// El as vive aqui y solo aqui.
// Validamos antes de brandear: la asercion es segura porque lo comprobamos.
function crearHeroeId(n: number): HeroeId {
if (n <= 0) {
throw new Error("HeroeId debe ser positivo, recibido: " + n);
}
return n as HeroeId;
}El tipo de retorno es HeroeId, así que el compilador ya sabe que lo que sale de crearHeroeId es un branded value correcto. Nadie más necesita hacer un as.
El coste en runtime: cero#
Esto es importante: la marca __brand no existe en runtime. Los tipos de TypeScript se borran al compilar. Un HeroeId en runtime es un número normal, typeof hId === "number" devuelve true, y no hay ninguna propiedad __brand en el objeto (porque no es un objeto: es un número).
// El branded type solo vive durante la compilacion.
// En el .js resultante no queda rastro de la marca.
type HeroeId = number & { readonly __brand: "HeroeId" };
const hId = 76 as HeroeId;Esa es la característica más valiosa del patrón: protección estática de coste cero en producción. El compilador hace la guardia durante el desarrollo; en producción el código es exactamente igual que si no hubiera branded types.
Por qué esto importa: el compilador como guardián#
Combinando branded types + smart constructors + firmas tipadas, se consigue algo que ningún if en runtime puede igualar:
// El compilador impide llamar a esta funcion con los ids intercambiados.
// No en runtime, con un mensaje de error: en compilacion, con un subrayado rojo.
function buscarPartida(heroeId: HeroeId, partidaId: PartidaId): string {
return "heroe=" + heroeId + " partida=" + partidaId;
}
const hId = crearHeroeId(76);
const pId = crearPartidaId(999);
// Correcto: compila.
buscarPartida(hId, pId);
// Error: PartidaId no es HeroeId.
// buscarPartida(pId, hId);En un proyecto bancario el mismo patrón sirve para distinguir céntimos de euros, cuentas de origen de cuentas de destino, o tokens de sesión de tokens de refresh. Cualquier par de cosas que comparten el mismo tipo primitivo pero significan cosas distintas.
Comprueba lo que sabes#
Pregunta 1 de 4
¿Por qué `type HeroeId = number` NO es un branded type?
Tu turno#
Define los branded types del Team Builder, crea los smart constructors y escribe una función que el compilador proteja de ids intercambiados. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un nivel al siguiente.
Ejercicio · en esta página
Branded types para el Team Builder
Defines branded types para HeroeId y PartidaId, creas sus smart constructors y escribes una función que el compilador impide llamar con los ids intercambiados.
Paso 1: Que los tipos sean incompatibles
- HeroeId y PartidaId están definidos como branded types (intersección + marca).
- Un number suelto no se puede asignar a HeroeId ni a PartidaId sin aserción.
- HeroeId y PartidaId tampoco son intercambiables entre sí.
- Los tests _t1 y _t2 quedan en verde (es decir, fallan al intentar Expect<Equal<...>>).
Paso 2: Que la creación esté controlada
- crearHeroeId y crearPartidaId son los únicos sitios donde vive el as.
- Ambas funciones validan que el número sea positivo antes de brandear.
- El tipo de retorno inferido es exactamente HeroeId / PartidaId (tests _t3 y _t4 en verde).
Paso 3: Que el compilador sea el guardián
- buscarPartida(heroeId: HeroeId, partidaId: PartidaId) compila y devuelve un string.
- Llamarla con los ids invertidos (pId, hId) marca error de compilación — el @ts-expect-error lo confirma.
- Pasarle números sueltos sin brandear también marca error.
- Tests _t5 y _t6 en verde.
Ver soluciones
// Solución OK — Define los branded types
//
// Lo mínimo: declarar los tipos con la marca.
// Un number normal ya no cuela donde se espera HeroeId o PartidaId.
// ─────────────────────────────────────────────
// Tipos de apoyo
// ─────────────────────────────────────────────
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
type Expect<T extends true> = T;
// ─────────────────────────────────────────────
// Branded types
// ─────────────────────────────────────────────
// HeroeId es un number con una marca que lo distingue de cualquier otro number.
// La intersección & añade una propiedad extra al tipo — pero SOLO a nivel de tipos:
// en runtime sigue siendo un number normal, la marca desaparece al compilar.
type HeroeId = number & { readonly __brand: "HeroeId" };
// Exactamente lo mismo para PartidaId: misma base, marca distinta.
type PartidaId = number & { readonly __brand: "PartidaId" };
// ─────────────────────────────────────────────
// Verificación manual: el compilador detecta la confusión
// ─────────────────────────────────────────────
// Un number suelto no es HeroeId: le falta la marca.
// @ts-expect-error
const _plain: HeroeId = 1;
// Un PartidaId no es HeroeId: marcas distintas.
// @ts-expect-error
const _confusionA: HeroeId = 1 as PartidaId;
// Un HeroeId no es PartidaId: en la dirección contraria, igual.
// @ts-expect-error
const _confusionB: PartidaId = 1 as HeroeId;
// ─────────────────────────────────────────────
// Tests de tipos
// ─────────────────────────────────────────────
// HeroeId y PartidaId son tipos distintos entre sí.
// @ts-expect-error
type _t1 = Expect<Equal<HeroeId, PartidaId>>;
// HeroeId tampoco es un number a secas: tiene la marca encima.
// @ts-expect-error
type _t2 = Expect<Equal<HeroeId, number>>; Por qué este nivel
- La intersección & es el mecanismo: añade una propiedad imaginaria que distingue el tipo. Un number suelto no tiene __brand, así que no encaja.
- Los @ts-expect-error confirman que el compilador rechaza las asignaciones cruzadas — si algún día el código compilara sin error, el @ts-expect-error marcaría rojo y lo notarías.
- El nivel ok demuestra el problema resuelto a nivel de tipos, pero no controla cómo se crean los valores branded. Cualquiera puede hacer `1 as HeroeId` sin validación. Eso lo arregla el nivel siguiente.
// Solución MEJOR — Smart constructors
//
// El branded type solo existe a nivel de tipos: para crear un valor branded
// hay que hacer una aserción `as`. Si esa aserción está repartida por todo
// el código, cualquiera puede saltarse la validación. La solución: un
// "smart constructor" que centraliza la creación y es el ÚNICO sitio donde
// vive el `as`. El resto del código solo llama a la función.
// ─────────────────────────────────────────────
// Tipos de apoyo
// ─────────────────────────────────────────────
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
type Expect<T extends true> = T;
// ─────────────────────────────────────────────
// Branded types
// ─────────────────────────────────────────────
type HeroeId = number & { readonly __brand: "HeroeId" };
type PartidaId = number & { readonly __brand: "PartidaId" };
// ─────────────────────────────────────────────
// Smart constructors
// ─────────────────────────────────────────────
// crearHeroeId es el ÚNICO lugar donde se hace `as HeroeId`.
// Valida primero: si el número no es un id positivo, lanza un error en runtime.
// Así el branded type lleva implícita la garantía de que el valor es válido.
function crearHeroeId(n: number): HeroeId {
if (n <= 0) {
throw new Error("HeroeId debe ser un entero positivo, recibido: " + n);
}
// La aserción es segura porque acabamos de validar el valor.
return n as HeroeId;
}
// Mismo patrón para PartidaId: validación + aserción en un único sitio.
function crearPartidaId(n: number): PartidaId {
if (n <= 0) {
throw new Error("PartidaId debe ser un entero positivo, recibido: " + n);
}
return n as PartidaId;
}
// ─────────────────────────────────────────────
// Uso: ahora el código no necesita `as` en ningún otro sitio
// ─────────────────────────────────────────────
const hId = crearHeroeId(76);
const pId = crearPartidaId(999);
// El tipo inferido ya es HeroeId / PartidaId, sin aserciones externas.
console.log("HeroeId creado: " + hId);
console.log("PartidaId creado: " + pId);
// ─────────────────────────────────────────────
// Tests de tipos
// ─────────────────────────────────────────────
// @ts-expect-error
type _t1 = Expect<Equal<HeroeId, PartidaId>>;
// @ts-expect-error
type _t2 = Expect<Equal<HeroeId, number>>;
// El smart constructor devuelve exactamente el branded type prometido.
type _t3 = Expect<Equal<ReturnType<typeof crearHeroeId>, HeroeId>>;
type _t4 = Expect<Equal<ReturnType<typeof crearPartidaId>, PartidaId>>; Por qué es mejor que el anterior
- El smart constructor es el ÚNICO sitio donde vive el `as`. Centralizar la aserción significa que si alguien quiere crear un HeroeId, pasa por la validación sí o sí.
- La validación en runtime (`if (n <= 0)`) y la garantía en compilación (el tipo de retorno `HeroeId`) trabajan juntas: el branded type dice qué tipo es, el constructor dice que además es positivo.
- El salto a excelente es demostrar que la función que consume los ids también está protegida: no basta con crear los valores correctamente, hay que usarlos en firmas que el compilador pueda vigilar.
// Solución EXCELENTE — Branded types en una función real
//
// El punto de llegada: una función que SOLO acepta los ids en el orden correcto.
// No basta con tener los tipos definidos; hay que usarlos en las firmas para que
// el compilador haga la guardia por nosotros. Ningún `if` en runtime puede dar esa
// garantía — el branded type la da en compilación, gratis y sin coste en runtime.
// ─────────────────────────────────────────────
// Tipos de apoyo
// ─────────────────────────────────────────────
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2)
? true
: false;
type Expect<T extends true> = T;
// ─────────────────────────────────────────────
// Branded types
// ─────────────────────────────────────────────
type HeroeId = number & { readonly __brand: "HeroeId" };
type PartidaId = number & { readonly __brand: "PartidaId" };
// ─────────────────────────────────────────────
// Smart constructors
// ─────────────────────────────────────────────
function crearHeroeId(n: number): HeroeId {
if (n <= 0) {
throw new Error("HeroeId debe ser un entero positivo, recibido: " + n);
}
return n as HeroeId;
}
function crearPartidaId(n: number): PartidaId {
if (n <= 0) {
throw new Error("PartidaId debe ser un entero positivo, recibido: " + n);
}
return n as PartidaId;
}
// ─────────────────────────────────────────────
// Función con firma branded: imposible pasar ids en el orden incorrecto
// ─────────────────────────────────────────────
// buscarPartida recibe HeroeId en el primer argumento y PartidaId en el segundo.
// Si inviertes el orden, el compilador marca error antes de que el código llegue
// a ejecutarse: no hay test en runtime que lo iguale.
function buscarPartida(heroeId: HeroeId, partidaId: PartidaId): string {
// En runtime los branded types son números normales: no hay overhead.
return "Heroe #" + heroeId + " en partida #" + partidaId;
}
// ─────────────────────────────────────────────
// Uso correcto: el compilador lo permite
// ─────────────────────────────────────────────
const hId = crearHeroeId(76);
const pId = crearPartidaId(999);
const resumen = buscarPartida(hId, pId);
console.log(resumen);
// ─────────────────────────────────────────────
// Uso incorrecto: el compilador lo IMPIDE
// ─────────────────────────────────────────────
// Aquí los ids están en orden incorrecto. Con number puro, TypeScript no diría nada.
// Con branded types, marca error: PartidaId no es HeroeId.
// @ts-expect-error
buscarPartida(pId, hId);
// Un number suelto tampoco pasa: le falta la marca.
// @ts-expect-error
buscarPartida(76, 999);
// ─────────────────────────────────────────────
// Tests de tipos
// ─────────────────────────────────────────────
// @ts-expect-error
type _t1 = Expect<Equal<HeroeId, PartidaId>>;
// @ts-expect-error
type _t2 = Expect<Equal<HeroeId, number>>;
type _t3 = Expect<Equal<ReturnType<typeof crearHeroeId>, HeroeId>>;
type _t4 = Expect<Equal<ReturnType<typeof crearPartidaId>, PartidaId>>;
// buscarPartida espera HeroeId en posición 0 y PartidaId en posición 1.
type _t5 = Expect<Equal<Parameters<typeof buscarPartida>[0], HeroeId>>;
type _t6 = Expect<Equal<Parameters<typeof buscarPartida>[1], PartidaId>>; Por qué es mejor que el anterior
- buscarPartida(heroeId: HeroeId, partidaId: PartidaId) convierte el compilador en guardián de la corrección: el error de "ids invertidos" se detecta en compilación, antes de que el código llegue a ejecutarse.
- El @ts-expect-error en la llamada incorrecta es la prueba: si algún día se arregla accidentalmente (o si el branding desaparece), esa línea marcaría rojo y lo verías al instante.
- En runtime, hId y pId son números normales — cero overhead. El branding es una protección estática de coste cero.