Tipos branded: distinguir un string de un email verificado
TypeScript compara tipos por su forma, no por su nombre, y de esa decisión se deriva que un identificador de usuario, un identificador de pedido, un correo verificado y un fragmento de HTML ya saneado sean para el compilador el mismo tipo intercambiable: `string`. Esta lección introduce los tipos `branded`, la técnica que añade una marca fantasma —presente en tiempo de compilación y ausente en tiempo de ejecución— para separar valores que comparten representación pero no significado. Y la lleva hasta donde se vuelve útil de verdad: el constructor inteligente, la única puerta por la que un valor puede adquirir la marca, que convierte una comprobación repetida por todo el código en una prueba de que la validación ya ocurrió y no hace falta repetirla.
Hay una clase de bug que ningún modelado de casos evita, porque no nace de un estado imposible sino de una confusión entre valores que el compilador considera idénticos. Pasas el identificador del pedido donde se esperaba el del usuario y compila; guardas en el estado una cadena que el usuario acaba de teclear en un campo llamado correo y todo el código que la consume asume, sin ninguna base, que es un correo válido; insertas en el DOM un texto que creías saneado y resulta que la función de saneamiento se aplicaba en otra rama. Los tres accidentes comparten causa: TypeScript compara tipos por su estructura, y bajo ese criterio un identificador, un correo verificado y un fragmento de HTML seguro son exactamente lo mismo, una secuencia de caracteres. Esta lección enseña a introducir en el sistema de tipos la distinción que la estructura no puede ver, y a colocar esa distinción donde importa: en la frontera entre el dato que alguien afirmó y el dato que alguien comprobó.
- Explicar por qué la tipificación estructural vuelve intercambiables valores con la misma representación y distinto significado.
- Construir un tipo
brandedcon una marca fantasma que exista en compilación y desaparezca en ejecución. - Encapsular la validación en un constructor inteligente que sea la única vía de obtener la marca.
- Reconocer los costes y los límites de la técnica, y decidir qué conceptos del dominio la merecen.
Por qué el compilador no distingue lo que tú distingues
TypeScript adopta tipificación estructural: dos tipos son compatibles si su forma lo es, sin importar cómo se llamen ni de dónde vengan. Es una decisión deliberada y acertada para un lenguaje que describe JavaScript, donde los objetos se crean sobre la marcha y nadie declara pertenencias.
El efecto colateral aparece en cuanto usas alias. Declarar type UsuarioId = string y type PedidoId = string no crea dos tipos: crea dos nombres para el mismo tipo, y por tanto una función que recibe uno acepta el otro sin una sola queja.
type UsuarioId = string;
type PedidoId = string;
function cancelar(pedido: PedidoId) { /* ... */ }
const usuario: UsuarioId = "u_42";
cancelar(usuario); // compila: para el compilador ambos son string
El alias documenta la intención y no la defiende, que es la peor combinación posible: da la sensación de seguridad sin la seguridad, y esa sensación es la que hace que nadie revise esa línea en el pull request.
El caso de los identificadores es el más citado y no es el más grave. El grave es el de los valores que han pasado por una comprobación, porque ahí la información que se pierde no es de qué entidad viene el dato, sino si ya se hizo un trabajo sobre él.
Una cadena que salió de un formulario y una cadena que superó una validación de correo son ambas string, así que el tipo no puede recordar la diferencia y el recuerdo se traslada a la memoria del equipo. En consecuencia, cada consumidor toma una de dos decisiones malas: o vuelve a validar por si acaso, y la validación se dispersa y se desincroniza, o confía sin base, y ese consumidor será el que falle.
Ese es el punto conceptual de la lección, y conviene enunciarlo antes de ver la sintaxis. Una validación es trabajo hecho, y el trabajo hecho debe quedar registrado en el tipo del resultado; si no lo queda, se pierde en cuanto el valor cruza una función y alguien tendrá que rehacerlo o suponerlo.
Marca: identidad sin representación
Una propiedad fantasma que solo existe para el compilador. No cambia el valor ni pesa en ejecución, pero rompe la compatibilidad estructural con el tipo base.
Constructor: la única puerta
La marca no se puede escribir a mano desde fuera. Se obtiene pasando por una función que valida, y por eso tenerla prueba que la validación ocurrió.
La marca fantasma
La técnica consiste en intersectar el tipo base con un objeto que declara una propiedad que ningún valor real tendrá jamás, y que sirve únicamente para que la comparación estructural falle.
declare const marca: unique symbol;
type Marcado<T, Nombre extends string> = T & {
readonly [marca]: Nombre;
};
type UsuarioId = Marcado<string, "UsuarioId">;
type PedidoId = Marcado<string, "PedidoId">;
type Email = Marcado<string, "Email">;
Lee con cuidado la primera línea, porque es la que hace el truco honesto. La palabra declare afirma la existencia del símbolo ante el compilador sin generar ninguna línea de JavaScript, de modo que la marca no existe en ejecución: un UsuarioId es, en tiempo de ejecución, una cadena y nada más, sin envoltorio, sin coste de memoria y sin sorpresas al serializar.
Que el símbolo sea unique y esté fuera del alcance de otros módulos impide además que alguien fabrique la propiedad por su cuenta. Y como la marca lleva un nombre distinto en cada tipo, UsuarioId y PedidoId tampoco son compatibles entre sí, que era el otro accidente que queríamos cerrar.
La consecuencia es asimétrica y esa asimetría es exactamente la deseada. Un Email se puede usar en cualquier sitio donde se espere un string, porque lo es y todas las operaciones de cadena siguen funcionando sobre él.
Pero un string cualquiera no se puede usar donde se espera un Email, porque le falta la marca y no hay manera legítima de inventarla. La marca funciona, en la práctica, como una firma que solo puede estampar quien tiene la validación.
Existe una alternativa clásica que consiste en envolver el valor en una clase con un campo privado, aprovechando que los campos privados sí rompen la compatibilidad estructural. Funciona, pero paga tres peajes: crea un objeto real en tiempo de ejecución, obliga a desenvolver el valor en cada uso y complica la serialización, porque al convertir a JSON hay que acordarse de extraer el contenido y al reconstruirlo hay que volver a envolverlo. La marca fantasma consigue la misma separación con coste cero en ejecución: los valores atraviesan la red, el localStorage y los límites de proceso como lo que son, cadenas y números corrientes. Lo que sí conviene entender es que esa levedad tiene su contrapartida: al no existir en ejecución, la marca no puede comprobarse en ejecución, y por eso todo el peso de la garantía recae en que la única puerta de entrada sea el constructor.
El constructor inteligente
La marca por sí sola no garantiza nada; garantiza quien controla cómo se obtiene. El patrón se llama constructor inteligente y consiste en exportar el tipo pero no la forma de crearlo, dejando una única función que valida y, solo si la validación pasa, devuelve el valor marcado.
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export type Email = Marcado<string, "Email">;
export function crearEmail(bruto: string): Email | null {
const limpio = bruto.trim().toLowerCase();
return EMAIL.test(limpio) ? (limpio as Email) : null;
}
export function enviarBienvenida(destino: Email) {
// aquí no se valida nada: el tipo ya lo prueba
}
Hay una única conversión forzada en todo el diseño, dentro del constructor, y ese es el punto: la técnica no elimina la aserción de tipo, la concentra en una línea auditable en lugar de dispersarla por todo el código.
Fíjate también en que el constructor no solo comprueba, también normaliza: recorta espacios y baja a minúsculas antes de marcar. Así la marca no significa únicamente que el valor es válido, sino que está en forma canónica, y todas las comparaciones posteriores dejan de necesitar normalización previa.
El efecto sobre el resto del programa es el que perseguíamos desde la primera línea de la lección. La función que envía el correo de bienvenida ya no comprueba nada, y no por descuido, sino porque su firma exige una prueba de que la comprobación ocurrió antes.
Esa transformación tiene un nombre reconocible: en lugar de validar —comprobar y devolver un booleano que se olvida en cuanto acaba el if— se analiza, que es comprobar y devolver un valor cuyo tipo recuerda el resultado. La diferencia entre las dos es que la primera reparte la responsabilidad entre todos los consumidores y la segunda la retiene en un punto.
flowchart LR B[string bruto sin garantias] --> C[crearEmail valida y normaliza] C -- no cumple --> N[null y el error se trata aqui] C -- cumple --> E[Email marcado] E --> U[funciones que ya no comprueban] style B fill:#f38ba8,color:#11111b style C fill:#cba6f7,color:#11111b style E fill:#a6e3a1,color:#11111b style U fill:#89b4fa,color:#11111b
Conviene además hacer explícito el resultado del fallo. Devolver null sirve para casos simples, pero en un formulario querrás saber por qué falló, y ahí el constructor devuelve una unión discriminada como las de la primera lección, con un miembro de éxito que lleva el valor marcado y otro de error que lleva el motivo.
Las dos técnicas del nivel encajan de forma natural: la unión modela el resultado de la comprobación y la marca viaja dentro del miembro que triunfó.
Qué marcar, qué no, y qué se paga
La técnica es barata pero no gratuita, y aplicarla a todo produce código ceremonioso que la gente acaba esquivando. Los tres tipos de valor que la merecen casi siempre son reconocibles.
El primero, los identificadores de entidades distintas que comparten representación, porque confundirlos produce fallos silenciosos que llegan hasta la base de datos. El segundo, los valores que han superado una comprobación cara o crítica: correo verificado, HTML saneado, ruta normalizada, token con firma comprobada. El tercero, las magnitudes con unidad, donde sumar milisegundos con segundos o céntimos con euros es un error de dominio que el tipo number no ve.
Los costes también conviene nombrarlos sin adornos. El primero es que los límites del sistema no conocen la marca: lo que llega de una API o del localStorage es texto sin marcar y hay que hacerlo pasar por el constructor, que es precisamente el trabajo de la lección siguiente.
El segundo es que las operaciones que producen un valor nuevo pierden la marca, y con razón: concatenar dos Email no produce un Email, así que el resultado vuelve a ser string y debe repasar el constructor si quiere recuperar la marca. El tercero es cultural: la marca solo garantiza mientras nadie use una conversión forzada para saltársela, y por eso el constructor debe ser la única salida pública del módulo y las conversiones deben quedar prohibidas fuera de él.
Escribir una aserción de tipo sobre una cadena cualquiera para que pase por un valor marcado destruye en una línea lo que la técnica construía, y lo hace sin dejar rastro en la compilación. Por eso la disciplina alrededor de la marca es tan importante como la marca: el tipo y su constructor viven en un módulo que no exporta otra vía de creación, la regla de estilo prohíbe las aserciones fuera de ese módulo y la revisión de código trata cualquier aserción nueva como lo que es, la petición explícita de saltarse una comprobación. La técnica no protege de quien decide engañarla; protege del olvido, que es de lo que hay que protegerse el noventa y nueve por ciento de las veces.
Quédate con este principio porque reordena la manera de escribir código defensivo. Cada vez que compruebas algo y sigues adelante con el mismo valor y el mismo tipo, acabas de hacer un trabajo cuyo resultado no queda registrado en ninguna parte salvo en el flujo de ejecución inmediato, y en cuanto ese valor cruce una función el conocimiento se habrá evaporado. Lo que ocurre después es siempre lo mismo: el siguiente consumidor no sabe si puede confiar, y como no puede saberlo elige mal en cualquiera de las dos direcciones, comprobando otra vez —y multiplicando el número de sitios donde vive la misma regla, que después divergen— o confiando a ciegas, que es la vía por la que llegan a producción los correos malformados y el HTML sin sanear. El tipo branded cierra ese agujero convirtiendo la comprobación en un cambio de tipo, y con ello la validación deja de ser un acto que se realiza y se olvida para convertirse en una propiedad que el valor arrastra allá donde vaya. Míralo en su forma más general y verás que trasciende la sintaxis: el sistema de tipos es la memoria del programa sobre lo que ya se ha demostrado, y todo lo que compruebas sin registrarlo ahí lo estás confiando a la memoria de las personas, que es la que falla el día del incidente. Pregúntate ante cada comprobación qué prueba acabas de obtener y dónde va a quedar escrita; si la respuesta es que en ninguna parte, tienes delante un candidato a marca.
- Busca dos identificadores distintos de tu dominio declarados como alias de
stringy comprueba que el compilador te deja intercambiarlos sin protestar. - Define el tipo
Marcadocon un símbolo único y redeclara esos dos identificadores como tipos marcados distintos. - Elige un valor que hoy se valide en varios sitios, escribe su constructor inteligente y haz que sea la única exportación capaz de producir la marca.
- Añade normalización dentro del constructor y elimina de los consumidores todas las normalizaciones repetidas que dejaron de ser necesarias.
- Cambia el resultado del constructor de un
nulla una unión discriminada con el motivo del fallo y observa cómo mejora el mensaje que puedes mostrar. - Recorre el código en busca de aserciones de tipo sobre valores marcados fuera de su módulo y anota cada una: es una garantía rota que aún no ha dado la cara.