learning-front

Extra · Type-level TypeScript (opcional)

Branded / nominal types

Cómo convertir un number en un HeroeId que el compilador distingue de un PartidaId: intersección con una marca, smart constructors y el coste real en runtime (cero).

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:

typescript
// 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.

typescript
// 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:

typescript
// 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.

typescript
// 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).

typescript
// 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:

typescript
// 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<...>>).
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.