El trimestre pasado, lanzamos un bug que reembolsó al cliente equivocado. Una función processRefund recibió un user ID donde esperaba un order ID. Las cadenas se veían idénticas, los tests pasaron y TypeScript no levantó ninguna objeción.
Ambos identificadores estaban tipados como string. TypeScript no ve ninguna diferencia entre ellos. Si alguna vez pasaste un userId a un parámetro que espera un orderId y viste al compiler encogerse de hombros, has chocado contra el mismo muro.
Los alias de tipo no arreglan esto. Tampoco el nombrar tus variables con cuidado. El compiler está haciendo exactamente lo que le dijiste que hiciera, y ese es el problema.
Por qué type UserId = string es una mentira
TypeScript usa tipado estructural. Dos tipos son compatibles si sus estructuras coinciden. Cuando escribes type UserId = string, no has creado un tipo nuevo. Has creado un alias. En tiempo de compilación, UserId y string son idénticos. Lo mismo ocurre con UserId y OrderId.
type UserId = string;
type OrderId = string;
function fetchOrder(id: OrderId) {
// ...
}
const userId: UserId = "usr_0192";
fetchOrder(userId); // Compila. Ups.
El compiler elimina los alias de tipo durante la comprobación de tipos. El nombre UserId es para los humanos. El comprobador de tipos lo ignora. Esto suele ser una característica. Te permite intercambiar implementaciones sin ceremonia. Pero para identificadores que nunca deberían ser intercambiables, es un arma cargada.
Los tipos branded hacen que estructuras idénticas sean incompatibles
La solución es un tipo branded, a veces llamado tipo opaco o newtype. Intersectas el tipo subyacente con una marca única que existe solo a nivel de tipos.
En tiempo de ejecución, el valor sigue siendo solo una cadena. En tiempo de compilación, la marca lo hace distinto de cualquier otra cadena.
Aquí está el patrón usando un unique symbol:
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };
El unique symbol garantiza que ningún otro tipo pueda compartir accidentalmente esta marca. La propiedad readonly significa que no puedes mutarla para eliminarla. En tiempo de ejecución, la propiedad símbolo no existe en la cadena real, así que esto es puramente una construcción en tiempo de compilación.
Para crear un valor, necesitas una función constructora que haga el cast de la cadena en bruto:
function UserId(value: string): UserId {
return value as UserId;
}
function OrderId(value: string): OrderId {
return value as OrderId;
}
Ahora el bug anterior se detecta antes incluso de ejecutar el código:
const uid = UserId("usr_0192");
fetchOrder(uid);
// ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
El mensaje de error es claro. Los tipos son estructuralmente diferentes porque sus marcas difieren. El compiler no te dejará mezclarlos.
El código repetitivo es molesto. Aquí va un helper.
Escribir una marca y un constructor para cada tipo de ID se hace viejo rápido. Usamos una pequeña utilidad que genera ambos:
interface Brand<T> {
readonly __brand: T;
}
type Branded<T, B> = T & Brand<B>;
function makeBrand<T, B>(
_brand: B
): (value: T) => Branded<T, B> {
return (value) => value as Branded<T, B>;
}
Uso:
type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");
type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");
Esto mantiene la declaración en dos líneas por identificador. También puedes usar el enfoque de unique symbol en un helper genérico si prefieres una mayor resistencia a colisiones. De cualquier forma, el objetivo es el mismo: hacer que el costo de agregar un nuevo tipo branded sea lo suficientemente bajo como para que realmente lo hagas.
¿Qué pasa con objetos y números?
Los tipos branded funcionan para cualquier primitivo, no solo cadenas. Marcamos los IDs numéricos de la misma forma:
type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");
También puedes marcar objetos. Si tienes dos configuraciones que comparten la misma forma pero nunca deberían intercambiarse, márcalas:
type ApiConfig = Branded<{
endpoint: string;
timeout: number;
}, "ApiConfig">;
Pero ten cuidado con el branding de objetos. El objeto en tiempo de ejecución no tendrá la propiedad de marca, así que JSON.stringify y las operaciones de spread se comportan normalmente. Eso suele ser lo que quieres. Pero si estás haciendo comprobaciones de igualdad profunda o pasando valores a bibliotecas que inspeccionan tipos en tiempo de ejecución, la marca no estará allí para ayudarte. Es estrictamente una guarda en tiempo de compilación.
Los compromisos son reales, pero pequeños
Los tipos branded añaden fricción. Ahora cada ID necesita una llamada al constructor. No puedes pasar una cadena en bruto inline a una función que espera un tipo branded. Ese es el punto, pero significa más código.
La serialización es otra trampa. Cuando haces JSON.stringify de una cadena branded, obtienes la cadena en bruto de vuelta. Cuando la parseas después, has perdido la marca. Necesitas reaplicar el constructor en los límites del sistema. Solemos hacer esto en los parsers de respuestas de API y en los mappers de filas de base de datos.
const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));
Los tipos branded tampoco ayudan con la validación en tiempo de ejecución. Si una cadena está malformada, la marca no lo detectará. Todavía necesitas zod, valibot o validación manual en los bordes de tu sistema. La marca garantiza distinción de tipos, no corrección de datos.
Cuándo vale la pena, y cuándo es ruido
Marcamos los identificadores que cruzan límites de modules: user IDs, organization IDs, trace IDs, span IDs. Estos son los valores que recorren más código y tienen la mayor probabilidad de ser pasados en el orden equivocado.
No marcamos contadores de bucles internos, variables temporales locales ni nada con un alcance menor que una función. El overhead no vale la pena para valores que nunca abandonan su lugar de nacimiento.
Si tu codebase tiene una firma de función con tres parámetros de cadena consecutivos, esa es una señal fuerte. Los tipos branded convierten ese sitio de llamada de un juego de adivinanzas en algo que el compiler comprueba por ti.
Un punto de partida práctico
No necesitas marcar cada cadena de tu codebase mañana. Elige los identificadores que han causado bugs reales. Agrega marcas para esos. Observa cómo el compiler detecta una mezcla durante tu próxima refactorización. Ese único momento, en el que un error de build evita un bug en producción, es cuando las llamadas extra al constructor empiezan a parecer baratas.
Empieza con un límite de module. Agrega una marca UserId. Agrega una marca OrderId. Actualiza tu capa de base de datos para aplicar los constructores cuando lleguen las filas. Mira cómo se siente. Si detecta un argumento equivocado en una code review, ya se ha pagado solo.