TypeScript usa un sistema de tipos estructural: dos tipos son compatibles si tienen la misma forma, independientemente de cómo se llamen. Esto es conveniente en la mayoría de casos, pero a veces queremos que UserId y ProductoId sean incompatibles aunque ambos sean number por debajo. Los branded types resuelven esto sin coste en runtime. Combinados con smart constructors, el type-state pattern y fluent APIs tipadas, forman la base de APIs donde el compilador controla el flujo de uso y hace imposible llamar métodos en el orden incorrecto.
Branded types: tipos nominales en TypeScript
// Sin branding: UserId y ProductoId son intercambiables (mal)
type UserId = number;
type ProductoId = number;
declare function obtenerUsuario(id: UserId): Usuario;
declare function obtenerProducto(id: ProductoId): Producto;
const idUsuario: UserId = 1;
obtenerProducto(idUsuario); // ¡No da error! Pero es un bug silencioso
// Con branding: se vuelven incompatibles
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<number, "UserId">;
type ProductoId = Brand<number, "ProductoId">;
const idUsuario = 1 as UserId;
obtenerProducto(idUsuario); // Error: 'UserId' no es asignable a 'ProductoId' ?
Smart constructors
Un smart constructor es una función que valida el valor en runtime y devuelve el tipo branded. Así el branding no puede aplicarse a valores arbitrarios:
// Email válido como tipo branded:
type Email = Brand<string, "Email">;
function crearEmail(valor: string): Email {
if (!/^[^@]+@[^@.]+.[^@.]+$/.test(valor)) {
throw new Error(`"${valor}" no es un email válido`);
}
return valor as Email; // único lugar donde se hace el cast
}
// PositiveInt:
type PositiveInt = Brand<number, "PositiveInt">;
function crearPositiveInt(n: number): PositiveInt {
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${n} no es un entero positivo`);
}
return n as PositiveInt;
}
// Uso:
const email = crearEmail("[email protected]"); // Email
const pagina = crearPositiveInt(1); // PositiveInt
// "hola" as Email // Posible pero requiere cast explícito: señal de alerta
Type-state pattern: el compilador controla el estado
El type-state pattern usa parámetros de tipo para representar el estado de un objeto. Según el estado, solo están disponibles ciertos métodos, haciendo imposible llamarlos en el orden incorrecto:
// Estados como tipos string literal:
type EstadoPedido = "borrador" | "confirmado" | "enviado" | "entregado";
class Pedido<E extends EstadoPedido> {
private constructor(
readonly id: number,
readonly estado: E,
readonly items: string[]
) {}
static crear(id: number): Pedido<"borrador"> {
return new Pedido(id, "borrador", []);
}
// Solo disponible en estado "borrador":
añadirItem(this: Pedido<"borrador">, item: string): Pedido<"borrador"> {
return new Pedido(this.id, "borrador", [...this.items, item]);
}
// Solo disponible en estado "borrador":
confirmar(this: Pedido<"borrador">): Pedido<"confirmado"> {
if (this.items.length === 0) throw new Error("El pedido está vacío");
return new Pedido(this.id, "confirmado", this.items);
}
// Solo disponible en estado "confirmado":
enviar(this: Pedido<"confirmado">): Pedido<"enviado"> {
return new Pedido(this.id, "enviado", this.items);
}
}
const pedido = Pedido.crear(1)
.añadirItem("Libro TypeScript")
.confirmar()
.enviar();
// pedido.añadirItem("Otro"); // Error: solo disponible en estado "borrador" ?
// pedido.confirmar(); // Error: solo disponible en estado "borrador" ?
Fluent API tipada: builder con estado
// Un builder de consultas SQL donde el compilador controla qué métodos van primero:
type Campos<C extends string = never> = { _campos: C };
type ConWhere = { _where: true };
class QueryBuilder<T extends object, Config extends object = object> {
private _tabla = "";
private _campos: string[] = ["*"];
private _condicion = "";
static desde<T extends object>(tabla: string): QueryBuilder<T> {
const q = new QueryBuilder<T>();
q._tabla = tabla;
return q;
}
seleccionar<C extends keyof T & string>(
...campos: C[]
): QueryBuilder<T, Config & Campos<C>> {
this._campos = campos;
return this as any;
}
where(
condicion: string
): QueryBuilder<T, Config & ConWhere> {
this._condicion = condicion;
return this as any;
}
// ejecutar solo disponible si hay una condición where
ejecutar(
this: QueryBuilder<T, Config & ConWhere>
): Promise<Pick<T, Extract<Config extends Campos<infer C> ? C : keyof T, keyof T>>[]> {
const sql = `SELECT ${this._campos.join(", ")} FROM ${this._tabla} WHERE ${this._condicion}`;
console.log(sql);
return Promise.resolve([]);
}
}
interface Producto { id: number; nombre: string; precio: number }
const resultados = await QueryBuilder
.desde<Producto>("productos")
.seleccionar("id", "nombre")
.where("precio > 10")
.ejecutar();
// resultados: Pick<Producto, "id" | "nombre">[]
// QueryBuilder.desde<Producto>("productos").ejecutar();
// Error: falta el where ?
Los branded types, los smart constructors y el type-state pattern no añaden ninguna lógica nueva al programa: son herramientas puramente de tipos que hacen que los errores aparezcan en el compilador en lugar de en producción. Su coste es cero en runtime y su beneficio es que la API se auto-documenta: si el código compila, los datos son válidos y las operaciones se han llamado en el orden correcto.
Imagen: Pexels / Bibek Ghosh
