Logo Gerardo Perrucci - Full Stack Developer

Branded Types y Zod: El Secreto del Ingeniero Senior para la Seguridad

Branded Types y Zod: El Secreto del Ingeniero Senior para la Seguridad

En más de doce años construyendo sistemas distribuidos, desde plataformas de trading de alta frecuencia hasta portales de juegos masivos, he descubierto que los bugs más peligrosos rara vez son los complejos. Son los aburridos.

El bug más insidioso en una base de código madura es la Obsesión por los Primitivos (Primitive Obsession).

Definimos nuestros mundos complejos en términos de string, number y boolean. Para nuestros compiladores, un UserId es matemáticamente idéntico a un TransactionId. Un StockPrice es solo un número, exactamente lo mismo que una Temperature.

Esto lleva al clásico desastre de "argumentos intercambiados":

// El compilador piensa que esto está perfectamente bien.
// Pero acabas de enviar dinero a la persona equivocada.
transfer(amount, toAddress, fromAddress);

Si la firma de tu función es transfer(amount: number, from: string, to: string), has desactivado efectivamente el sistema de tipos para esos argumentos. Estás volando a ciegas.

En este post, te voy a mostrar cómo solucionar esto usando Branded Types y Zod. Vamos a pasar de "confiar en el desarrollador" a "imponer la arquitectura".

Table of Contents

El Problema: TypeScript es Estructural

Para resolver esto, tenemos que luchar contra la naturaleza de TypeScript. TypeScript usa un sistema de tipos estructural. Si parece un pato, el compilador lo acepta como un pato.

interface Admin { id: string; }  
interface User { id: string; }
 
const admin: Admin = { id: "admin_1" };  
const user: User = admin; // ✅ Válido en TypeScript!

Esto es genial para la flexibilidad pero terrible para el modelado de dominio. Necesitamos Tipado Nominal (Nominal Typing). Necesitamos un sistema donde un User sea distinto de un Admin porque nosotros lo decimos, no solo porque casualmente tengan los mismos campos.

Dado que TypeScript no soporta esto nativamente, tenemos que mentirle al compilador. Es una mentira útil.

La Solución: La Marca "Unique Symbol"

He visto muchas formas de implementar tipos Branded, incluyendo clases y campos privados. La mayoría tiene sobrecarga en runtime o ensucia tu IntelliSense.

Después de años de iteración, este es el único patrón que recomiendo para producción. Usa unique symbol para crear una propiedad "privada" que existe solo en el sistema de tipos.

// branding.ts
 
// 1. Declarar un unique symbol.
// Esto existe solo en el espacio de tipos si no exportamos el valor.
declare const __brand: unique symbol;
 
// 2. Definir la utilidad genérica
export type Brand<T, B> = T & { [__brand]: B };
 
// 3. Definir tus tipos de dominio
export type UserId = Brand<string, "UserId">;  
export type Email = Brand<string, "Email">;  
export type Cents = Brand<number, "Cents">;

Este enfoque gana por tres razones. Primero, tiene Costo de Runtime Cero porque la intersección desaparece después de la compilación. Segundo, ofrece máxima seguridad porque __brand es un símbolo único, por lo que ningún otro tipo puede coincidir accidentalmente. Tercero, mantiene tus objetos en runtime limpios sin propiedades _type feas ensuciando tus logs de consola.

Ahora, si intentas asignar un string plano a un UserId, TypeScript te grita.

const id: UserId = "123";   
// ❌ Error: Type 'string' is not assignable to type 'UserId'

El Guardián en Runtime: Integrando Zod

Esto crea un nuevo problema. Si no podemos asignar un string a un UserId, ¿cómo creamos uno?

Podrías hacer un cast manual usando as UserId, pero eso derrota el propósito. El casting manual es peligroso. Necesitamos una forma rigurosa de "bendecir" datos crudos en nuestros tipos confiables.

Aquí es donde Zod brilla. Usamos Zod para validar la estructura en el límite de I/O. Si pasa la validación, lo transformamos en nuestra brand (marca).

El Patrón "Parse, Don't Validate"

Rara vez uso el método nativo .brand() de Zod porque te encierra en los tipos internos de Zod. En su lugar, uso un pipeline de transformación personalizado.

import { z } from "zod";  
import { UserId, Email } from "./types";
 
// El Guardián
export const UserIdSchema = z.string()  
.uuid() // 1. Validar estructura primero
.transform((val) => val as UserId); // 2. "Bendecir" el tipo
 
export const EmailSchema = z.string()  
.email()  
.toLowerCase() // La normalización es gratis aquí!
.transform((val) => val as Email);

¿Por qué es as UserId seguro aquí? Es seguro porque vive dentro del pipeline de Zod. Solo hacemos cast del valor después de que Zod ha verificado que es un UUID válido. Hemos encapsulado la operación "insegura" dentro de una fábrica verificada.

Poniéndolo Todo Junto: La Arquitectura

Así es como se ve esto en una aplicación Next.js o Node.js real.

1. La Capa de Dominio (Core)

Tu lógica de negocio se vuelve autodocumentada. Es imposible llamar a esta función con un string no validado.

// domain/users.ts  
import { UserId, Email } from "@/types";
 
export async function activateUser(id: UserId, email: Email) {  
  // Si estamos aquí, 'id' está garantizado ser un UUID válido
  // y 'email' está garantizado ser un email válido y en minúsculas.
  console.log(`Activating ${id}...`);  
}

2. La Capa de Entrada (API/Actions)

Este es el único lugar donde existen strings crudos.

// actions.ts  
import { UserIdSchema, EmailSchema } from "@/schemas";  
import { activateUser } from "@/domain/users";
 
export async function handleRequest(rawInput: unknown) {  
  const payload = z.object({  
    id: UserIdSchema,  
    email: EmailSchema,  
  }).safeParse(rawInput);
 
  if (!payload.success) {  
    return { error: "Invalid data" };  
  }
 
  // Payload.data.id ahora está tipado como `UserId` automáticamente!
  await activateUser(payload.data.id, payload.data.email);  
}

3. La Capa de Base de Datos (Drizzle ORM)

Si tu base de datos devuelve strings planos, pierdes la cadena de seguridad. Drizzle ORM nos permite imponer brands a nivel de esquema usando $type.

// db/schema.ts  
import { pgTable, text } from "drizzle-orm/pg-core";  
import { UserId } from "@/types";
 
export const users = pgTable("users", {  
  // Dile a Drizzle: "Esta columna es un UserId, no solo un string"
  id: text("id").$type<UserId>().primaryKey(),   
  name: text("name"),  
});

Ahora, cuando ejecutas db.select().from(users), el resultado tiene automáticamente id: UserId. La seguridad se extiende desde tu base de datos hasta tus componentes frontend.

¿Cuándo Deberías Usar Esto?

No aplico "brand" a todo. Hacer branding de FirstName o Description es usualmente excesivo porque esos son solo texto.

Uso la regla de "Identidad y Criticidad". Aplico brands a tres categorías específicas.

  1. IDs: UserId, OrderId, ProductId. Mezclar estos es fatal para la integridad de los datos.
  2. Unidades de Medida: Segundos, Milisegundos, Centavos. La NASA perdió un orbitador de Marte debido a este error (https://www.simscale.com/blog/nasa-mars-climate-orbiter-metric/), y tú podrías perder un cliente.
  3. Datos Sensibles: HashedPassword, SanitizedHtml. Esto evita que accidentalmente renderices una contraseña en la UI.

Pensamientos Finales

Los Branded Types representan más que solo un truco inteligente de TypeScript. Son una declaración de intención. Nos permiten escribir código que se alinea con Domain-Driven Design, donde nuestras firmas de funciones hablan el lenguaje del negocio en lugar del lenguaje de la CPU.

Al combinar las garantías en tiempo de compilación de los Branded Types con la verificación en tiempo de ejecución de Zod, creas una estrategia de defensa en profundidad que elimina categorías enteras de bugs antes de que lleguen a producción.

Deja de pasar strings. Empieza a pasar significado.

Referencias

¿Tienes preguntas sobre este tema?

Agenda una llamada para discutir cómo puedo ayudar con tu proyecto