Builder pattern con tipado progresivo
El patrón Builder permite construir objetos complejos paso a paso. En TypeScript, se puede tipar de forma que el compilador rastree que propiedades se han configurado y solo permita llamar a build() cuando el objeto está completo.

interface ConfiguracionHTTP {
url: string
método: "GET" | "POST" | "PUT" | "DELETE"
headers: Record<string, string>
timeout: number
}
class HTTPBuilder<T extends Partial<ConfiguracionHTTP> = {}> {
private config: Partial<ConfiguracionHTTP> = {}
url(url: string): HTTPBuilder<T & { url: string }> {
this.config.url = url
return this as any
}
método(método: ConfiguracionHTTP["método"]): HTTPBuilder<T & { método: string }> {
this.config.método = método
return this as any
}
header(nombre: string, valor: string): HTTPBuilder<T & { headers: Record<string, string> }> {
this.config.headers = { ...this.config.headers, [nombre]: valor }
return this as any
}
timeout(ms: number): HTTPBuilder<T & { timeout: number }> {
this.config.timeout = ms
return this as any
}
build(
this: HTTPBuilder<{ url: string, método: string }>
): ConfiguracionHTTP {
return {
url: (this as any).config.url,
método: (this as any).config.método,
headers: (this as any).config.headers ?? {},
timeout: (this as any).config.timeout ?? 5000
}
}
}
// Correcto: url y método están configurados
const peticion = new HTTPBuilder()
.url("https://api.ejemplo.com/datos")
.método("GET")
.header("Authorization", "Bearer token123")
.timeout(3000)
.build()
console.log(peticion)
// Error de compilación: falta método()
// new HTTPBuilder().url("https://api.ejemplo.com").build()
El patrón Builder tipado garantiza en tiempo de compilación que los campos obligatorios están configurados antes de construir el objeto. Los campos opcionales pueden omitirse sin afectar la compilación.
Builder con validación de orden
En algunos casos, el orden de las operaciones importa. Se puede modelar esto con tipos que solo exponen ciertos métodos en cada etapa:
interface PipelineInicio {
origen(tabla: string): PipelineFiltro
}
interface PipelineFiltro {
filtrar(condición: string): PipelineFiltro
ordenar(campo: string, dirección: "asc" | "desc"): PipelineOrden
ejecutar(): string
}
interface PipelineOrden {
limitar(cantidad: number): PipelineFinal
ejecutar(): string
}
interface PipelineFinal {
ejecutar(): string
}
class QueryBuilder implements PipelineInicio, PipelineFiltro, PipelineOrden, PipelineFinal {
private partes: string[] = []
origen(tabla: string): PipelineFiltro {
this.partes.push(`SELECT * FROM ${tabla}`)
return this
}
filtrar(condición: string): PipelineFiltro {
this.partes.push(`WHERE ${condición}`)
return this
}
ordenar(campo: string, dirección: "asc" | "desc"): PipelineOrden {
this.partes.push(`ORDER BY ${campo} ${dirección.toUpperCase()}`)
return this
}
limitar(cantidad: number): PipelineFinal {
this.partes.push(`LIMIT ${cantidad}`)
return this
}
ejecutar(): string {
return this.partes.join(" ")
}
}
function crearQuery(): PipelineInicio {
return new QueryBuilder()
}
const consulta = crearQuery()
.origen("usuarios")
.filtrar("activo = true")
.ordenar("nombre", "asc")
.limitar(10)
.ejecutar()
console.log(consulta)
// SELECT * FROM usuarios WHERE activo = true ORDER BY nombre ASC LIMIT 10
Sistemas de eventos tipados
Un sistema de eventos tipado garantiza que los manejadores reciben exactamente el tipo de dato correspondiente a cada evento:
interface MapaEventos {
"usuario:login": { userId: string, timestamp: number }
"usuario:logout": { userId: string }
"producto:creado": { productoId: string, nombre: string, precio: number }
"producto:eliminado": { productoId: string }
"error": { código: number, mensaje: string }
}
type NombreEvento = keyof MapaEventos
class EventBus {
private listeners = new Map<string, Set<Function>>()
on<E extends NombreEvento>(
evento: E,
callback: (datos: MapaEventos[E]) => void
): () => void {
if (!this.listeners.has(evento)) {
this.listeners.set(evento, new Set())
}
this.listeners.get(evento)!.add(callback)
// Devuelve función para cancelar suscripcion
return () => {
this.listeners.get(evento)?.delete(callback)
}
}
emit<E extends NombreEvento>(evento: E, datos: MapaEventos[E]): void {
const callbacks = this.listeners.get(evento)
if (callbacks) {
for (const cb of callbacks) {
cb(datos)
}
}
}
}
const bus = new EventBus()
// TypeScript infiere el tipo del parámetro automáticamente
bus.on("usuario:login", (datos) => {
console.log(`Login: ${datos.userId} a las ${datos.timestamp}`)
})
bus.on("producto:creado", (datos) => {
console.log(`Nuevo producto: ${datos.nombre} - ${datos.precio} EUR`)
})
const cancelar = bus.on("error", (datos) => {
console.error(`Error ${datos.código}: ${datos.mensaje}`)
})
bus.emit("usuario:login", { userId: "usr_001", timestamp: Date.now() })
bus.emit("producto:creado", { productoId: "prod_01", nombre: "Monitor", precio: 299 })
// Error de compilación: faltan propiedades obligatorias
// bus.emit("producto:creado", { productoId: "prod_01" })
// Error de compilación: evento inexistente
// bus.emit("evento:falso", {})
cancelar() // Cancela la suscripcion al evento "error"
Sistema de eventos con once
class EventBusExtendido extends EventBus {
once<E extends NombreEvento>(
evento: E,
callback: (datos: MapaEventos[E]) => void
): void {
const cancelar = this.on(evento, (datos) => {
cancelar()
callback(datos)
})
}
esperarEvento<E extends NombreEvento>(
evento: E
): Promise<MapaEventos[E]> {
return new Promise((resolve) => {
this.once(evento, resolve)
})
}
}
const busExtendido = new EventBusExtendido()
// Uso con async/await
async function esperarLogin() {
const datos = await busExtendido.esperarEvento("usuario:login")
console.log(`Usuario ${datos.userId} ha iniciado sesion`)
}
Máquinas de estado con discriminated unions
Las máquinas de estado modelan sistemas que transicionan entre estados definidos. Las discriminated unions garantizan que cada estado tenga exactamente los datos que le corresponden:
type EstadoPedido =
| { estado: "borrador", items: string[] }
| { estado: "confirmado", items: string[], total: number, fechaConfirmacion: Date }
| { estado: "pagado", items: string[], total: number, transaccionId: string }
| { estado: "enviado", items: string[], total: number, codigoSeguimiento: string }
| { estado: "entregado", items: string[], total: number, fechaEntrega: Date }
| { estado: "cancelado", motivo: string }
function confirmarPedido(pedido: EstadoPedido & { estado: "borrador" }): EstadoPedido {
if (pedido.items.length === 0) {
return { estado: "cancelado", motivo: "Pedido sin items" }
}
const total = pedido.items.length * 10 // Precio simulado
return {
estado: "confirmado",
items: pedido.items,
total,
fechaConfirmacion: new Date()
}
}
function pagarPedido(
pedido: EstadoPedido & { estado: "confirmado" },
transaccionId: string
): EstadoPedido {
return {
estado: "pagado",
items: pedido.items,
total: pedido.total,
transaccionId
}
}
function enviarPedido(
pedido: EstadoPedido & { estado: "pagado" },
codigoSeguimiento: string
): EstadoPedido {
return {
estado: "enviado",
items: pedido.items,
total: pedido.total,
codigoSeguimiento
}
}
function describirPedido(pedido: EstadoPedido): string {
switch (pedido.estado) {
case "borrador":
return `Borrador con ${pedido.items.length} items`
case "confirmado":
return `Confirmado: ${pedido.total} EUR el ${pedido.fechaConfirmacion.toLocaleDateString()}`
case "pagado":
return `Pagado: transaccion ${pedido.transaccionId}`
case "enviado":
return `Enviado: seguimiento ${pedido.codigoSeguimiento}`
case "entregado":
return `Entregado el ${pedido.fechaEntrega.toLocaleDateString()}`
case "cancelado":
return `Cancelado: ${pedido.motivo}`
default:
const _exhaustivo: never = pedido
throw new Error(`Estado no manejado`)
}
}
let pedido: EstadoPedido = { estado: "borrador", items: ["Laptop", "Raton"] }
console.log(describirPedido(pedido))
if (pedido.estado === "borrador") {
pedido = confirmarPedido(pedido)
console.log(describirPedido(pedido))
}
if (pedido.estado === "confirmado") {
pedido = pagarPedido(pedido, "txn_abc123")
console.log(describirPedido(pedido))
}
if (pedido.estado === "pagado") {
pedido = enviarPedido(pedido, "TRACK_789")
console.log(describirPedido(pedido))
}
Transiciones válidas como tipo
Se puede modelar que transiciones de estado son válidas para evitar saltos ilegales:
type TransicionesValidas = {
borrador: "confirmado" | "cancelado"
confirmado: "pagado" | "cancelado"
pagado: "enviado"
enviado: "entregado"
entregado: never
cancelado: never
}
type EstadoNombre = keyof TransicionesValidas
function puedeTransicionar(
desde: EstadoNombre,
hacia: EstadoNombre
): boolean {
const transiciones: Record<EstadoNombre, readonly EstadoNombre[]> = {
borrador: ["confirmado", "cancelado"],
confirmado: ["pagado", "cancelado"],
pagado: ["enviado"],
enviado: ["entregado"],
entregado: [],
cancelado: []
}
return transiciones[desde].includes(hacia)
}
console.log(puedeTransicionar("borrador", "confirmado")) // true
console.log(puedeTransicionar("borrador", "enviado")) // false
console.log(puedeTransicionar("entregado", "borrador")) // false
Respuestas de API tipadas
El tipado avanzado permite modelar respuestas de API donde el tipo de datos depende del endpoint o del código de estado:
interface MapaEndpoints {
"/usuarios": { id: string, nombre: string, email: string }[]
"/usuarios/:id": { id: string, nombre: string, email: string }
"/productos": { id: string, nombre: string, precio: number }[]
"/productos/:id": { id: string, nombre: string, precio: number }
}
type RespuestaAPI<T> =
| { estado: "exito", datos: T, código: 200 }
| { estado: "no_encontrado", mensaje: string, código: 404 }
| { estado: "error_servidor", mensaje: string, código: 500 }
| { estado: "no_autorizado", mensaje: string, código: 401 }
async function fetchAPI<E extends keyof MapaEndpoints>(
endpoint: E
): Promise<RespuestaAPI<MapaEndpoints[E]>> {
try {
const response = await fetch(endpoint)
if (response.ok) {
const datos = await response.json() as MapaEndpoints[E]
return { estado: "exito", datos, código: 200 }
}
if (response.status === 404) {
return { estado: "no_encontrado", mensaje: "Recurso no encontrado", código: 404 }
}
if (response.status === 401) {
return { estado: "no_autorizado", mensaje: "Autenticacion requerida", código: 401 }
}
return { estado: "error_servidor", mensaje: "Error interno", código: 500 }
} catch {
return { estado: "error_servidor", mensaje: "Error de conexión", código: 500 }
}
}
async function ejemplo() {
const respuesta = await fetchAPI("/usuarios")
switch (respuesta.estado) {
case "exito":
// TypeScript sabe que datos es el array de usuarios
for (const usuario of respuesta.datos) {
console.log(`${usuario.nombre}: ${usuario.email}`)
}
break
case "no_encontrado":
console.log(respuesta.mensaje)
break
case "no_autorizado":
console.log("Redirigiendo a login...")
break
case "error_servidor":
console.error(respuesta.mensaje)
break
}
}
Readonly profundo y DeepFreeze
El patrón DeepReadonly convierte recursivamente todas las propiedades de un tipo en readonly, incluyendo objetos anidados y arrays:
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
interface Configuración {
servidor: {
host: string
puerto: number
ssl: {
activo: boolean
certificado: string
}
}
base_datos: {
url: string
opciones: string[]
}
}
function congelarProfundo<T extends object>(obj: T): DeepReadonly<T> {
for (const clave of Object.keys(obj)) {
const valor = (obj as any)[clave]
if (typeof valor === "object" && valor !== null) {
congelarProfundo(valor)
}
}
return Object.freeze(obj) as DeepReadonly<T>
}
const config = congelarProfundo({
servidor: {
host: "localhost",
puerto: 3000,
ssl: {
activo: true,
certificado: "/ruta/cert.pem"
}
},
base_datos: {
url: "postgres://localhost/app",
opciones: ["sslmode=require"]
}
})
console.log(config.servidor.host) // localhost
// Todos estos producen error de compilación:
// config.servidor.host = "otro"
// config.servidor.ssl.activo = false
// config.base_datos.opciones.push("nueva")
DeepPartial para actualizaciones parciales
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
function fusionarProfundo<T extends object>(
base: T,
actualizacion: DeepPartial<T>
): T {
const resultado = { ...base }
for (const clave in actualizacion) {
const valor = actualizacion[clave]
if (
typeof valor === "object" &&
valor !== null &&
!Array.isArray(valor) &&
typeof (base as any)[clave] === "object"
) {
(resultado as any)[clave] = fusionarProfundo(
(base as any)[clave],
valor as any
)
} else if (valor !== undefined) {
(resultado as any)[clave] = valor
}
}
return resultado
}
const configBase: Configuración = {
servidor: {
host: "localhost",
puerto: 3000,
ssl: { activo: false, certificado: "" }
},
base_datos: {
url: "postgres://localhost/dev",
opciones: []
}
}
const configProduccion = fusionarProfundo(configBase, {
servidor: {
host: "producción.ejemplo.com",
ssl: { activo: true, certificado: "/ruta/cert.pem" }
},
base_datos: {
url: "postgres://db.ejemplo.com/prod"
}
})
console.log(configProduccion.servidor.host) // producción.ejemplo.com
console.log(configProduccion.servidor.puerto) // 3000 (preservado de la base)
console.log(configProduccion.servidor.ssl.activo) // true
Estos patrones de tipado avanzado explotan las capacidades del sistema de tipos de TypeScript para trasladar verificaciones del tiempo de ejecución al tiempo de compilación. El Builder tipado, los eventos genéricos, las máquinas de estado con uniones discriminadas y los tipos recursivos como DeepReadonly permiten modelar dominios complejos con garantias que el compilador verifica automáticamente.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, TypeScript es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de TypeScript
Explora más contenido relacionado con TypeScript y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Implementar el patrón Builder con tipado progresivo y method chaining. Disenar sistemas de eventos tipados con genéricos. Modelar máquinas de estado con discriminated unions. Aplicar exhaustive switch con never para garantizar cobertura completa.