Patrones avanzados de TypeScript: branded types, builder pattern tipado y fluent APIs

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

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP