Menu

Branded types en TypeScript: tipado nominal con ejemplos

Un branded type es un primitivo con una etiqueta invisible, como string & { readonly __brand: "UserId" }, para que un UserId no se pueda pasar donde se espera un OrderId. Aprende cómo funcionan las marcas, las funciones constructoras que validan, un helper genérico Brand, las marcas con unique symbol y los números con marca.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

TypeScript compara los tipos por su forma, así que dos alias de string son intercambiables. Un branded type añade una etiqueta que solo existe en el sistema de tipos, string & { readonly __brand: "UserId" }, que hace que un UserId sea incompatible con un string normal y con cualquier otra marca:

En ejecución, userId es solo el string "u_42". La marca es una etiqueta de compilación, y su única función es impedir que se mezclen IDs, unidades y strings validados.

El problema: los alias son solo nombres

Un type alias no crea un tipo nuevo. Da un segundo nombre a un tipo que ya existe, y el compilador trata los dos nombres como la misma cosa:

Esto es tipado estructural: TypeScript comprueba que la forma encaja, y string encaja con string. En los objetos las formas suelen diferir; en IDs, emails, monedas y unidades, nunca. Las marcas resuelven ese caso concreto.

Cómo funciona la marca

string & { readonly __brand: "UserId" } es una intersección: un valor tiene que ser un string y además tener una propiedad __brand de tipo "UserId". Ningún string real tiene esa propiedad, así que ningún string normal se le puede asignar:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

La dirección que importa sigue funcionando: un UserId es un string, así que puedes pasarlo a cualquier cosa que reciba un string, llamar a .startsWith() sobre él o meterlo en una plantilla. La marca solo bloquea la entrada.

Funciones constructoras que validan

La aserción as UserId es la única forma de entrar, y una aserción no comprueba nada. Ponla en una función que valide la entrada, y así sabes que todo valor con marca del programa ha pasado esa comprobación:

sendWelcome nunca vuelve a comprobar su entrada, porque el tipo de su parámetro dice que la comprobación ya se hizo. Es la idea de «parse, don't validate»: comprueba en la frontera y lleva la prueba en el tipo. Un type guard también sirve como constructor si prefieres un booleano a una excepción: function isEmail(s: string): s is Email.

Un helper genérico Brand

Escribir la intersección a mano para cada tipo se vuelve repetitivo. Un genérico pequeño lo hace una sola vez:

Marcar un número funciona igual que marcar un string. Fíjate en que amount / 100 es un number normal: la aritmética sobre un número con marca da un resultado sin marca, como se explica más abajo.

Marcas con unique symbol

Un nombre de propiedad string como __brand parece una propiedad real: userId.__brand pasa la comprobación como "UserId" pero es undefined en ejecución, y dos librerías podrían elegir el mismo nombre. Una clave unique symbol evita las dos cosas:

declare const brand: unique symbol declara un símbolo que solo existe para el comprobador de tipos; la palabra clave declare significa que no se genera JavaScript para él. Como el símbolo no se exporta de su módulo, el código de otros archivos ni siquiera puede nombrar la propiedad de la marca, así que fuera de ese módulo las únicas formas de obtener un Meters son las funciones que exportes o una aserción as Meters.

Las marcas no cuestan nada en ejecución

La salida compilada no contiene ni rastro de la marca. Estas son las líneas generadas para el ejemplo de Meters, debajo de la cabecera de módulo que añade el compilador: el declare, los dos type aliases y todos los as han desaparecido, y la llamada suprimida de la última línea se sigue ejecutando.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

Un valor con marca es el primitivo tal cual: typeof da "string" o "number", JSON.stringify lo escribe como siempre y las comparaciones funcionan igual que antes. La otra cara es que no se comprueba nada en ejecución salvo lo que compruebe tu función constructora. Los datos que se leen de JSON, de una base de datos o de una URL llegan como string, y solo pasan a ser un UserId cuando los pasas por esa función.

La aritmética y los métodos pierden la marca

Las operaciones sobre un valor con marca devuelven el tipo base, porque la marca no forma parte de lo que producen + o .slice():

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

Normalmente es lo que quieres: sumar dos importes en céntimos da céntimos, pero multiplicar céntimos por céntimos no, y solo tú sabes qué operaciones conservan el significado. Escribe helpers pequeños como addCents(a: Cents, b: Cents): Cents para las operaciones que necesite tu código.

Cuándo usar branded types

Usa marcas donde confundir dos valores del mismo tipo primitivo sea un riesgo real y el compilador no pueda ayudarte de otra forma:

SituaciónMarcas de ejemplo
IDs de tablas distintasUserId, OrderId, ProductId
Strings validadosEmail, Url, NonEmptyString, Slug
Unidades y monedasMeters, Feet, Cents, Usd, Eur
Texto saneado o escapadoSafeHtml, SqlIdentifier
Números con un rangoPercentage, PositiveInt

No las uses para valores que nunca se confunden ni para tipos objeto que ya tienen formas distintas. Las librerías de validación pueden producir branded types a partir de un esquema: en Zod, z.string().brand<"UserId">() da un esquema cuyo parse devuelve un UserId con marca, lo que te ahorra escribir las funciones constructoras a mano.

Preguntas frecuentes

¿Qué son los branded types en TypeScript?

Un patrón que hace incompatibles dos tipos con la misma representación en ejecución. Intersecas el tipo base con una etiqueta que ningún valor normal tiene: type UserId = string & { readonly __brand: "UserId" }. Un string normal, o un OrderId con otra etiqueta, se rechaza entonces donde se espera un UserId.

¿TypeScript tiene tipos nominales?

No. El sistema de tipos de TypeScript es estructural: dos tipos con la misma forma son intercambiables, se llamen como se llamen. Las clases con miembros private o #private se comportan de forma nominal, y los branded types son la forma habitual de conseguir el mismo efecto con primitivos como strings y números.

¿Los branded types tienen coste en tiempo de ejecución?

No. La marca solo existe en el tipo. En ejecución el valor sigue siendo un string o un número normal, sin propiedades de más, y el JavaScript compilado es el mismo que sin la marca. El único código en ejecución es la validación que decidas poner en la función que crea los valores con marca.

¿Cómo creo un valor de un branded type?

Con una aserción de tipo, idealmente dentro de una función pequeña que primero compruebe la entrada: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Tener el as en ese único sitio significa que todo Email del programa ha pasado la comprobación.

¿Qué diferencia hay entre un type alias y un branded type?

type UserId = string es solo un nombre nuevo: cualquier string se acepta donde se espera un UserId. type UserId = string & { readonly __brand: "UserId" } es un tipo nuevo e incompatible: un string normal tiene que pasar antes por una función constructora o por una aserción.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR