Programación funcional en TypeScript: patrones prácticos sin dogmatismo
No hace falta un monad ni una librería de teoría de categorías para escribir TypeScript más predecible. Patrones funcionales que aportan claridad real, con código moderno y sin dogmatismo.
Hay dos formas de estropear un artículo sobre programación funcional. Una es venderla como la única forma correcta de escribir código, con Either, Task y una jerga de teoría de categorías que espanta a cualquiera que solo quería escribir una función más predecible. La otra es descartarla entera porque “eso es para Haskell”, cuando en realidad ya usas programación funcional cada vez que escribes array.map(x => x * 2) en vez de un bucle for con un acumulador mutable.
Este artículo va del término medio: qué patrones funcionales aportan claridad real en TypeScript del día a día, con código que compila y que un compañero puede leer sin haber estudiado cálculo lambda, y dónde la misma idea, llevada un paso más allá, empieza a ser sobreingeniería.
Funciones puras: la unidad básica, no el objetivo final
Una función pura cumple dos condiciones: dado el mismo input, siempre devuelve el mismo output, y no produce efectos observables fuera de sí misma (no muta un argumento, no escribe en una variable externa, no hace I/O).
// Impura: depende de un reloj externo y no es predecible en tests
function calcularDescuento(precio: number): number {
const esFinDeSemana = new Date().getDay() % 6 === 0;
return esFinDeSemana ? precio * 0.9 : precio;
}
// Pura: el resultado depende solo de los argumentos
function calcularDescuento(precio: number, esFinDeSemana: boolean): number {
return esFinDeSemana ? precio * 0.9 : precio;
}
La segunda versión no es “más funcional” por elegancia estética: es más fácil de testear (no necesitas mockear Date), más fácil de razonar en una revisión de código (todo lo que necesitas saber está en la firma) y más fácil de reutilizar en un contexto distinto (un cron job, un test, una simulación). Esa ganancia práctica —no la pureza como ideología— es el motivo real para preferir funciones puras donde sea razonable.
Inmutabilidad: dejar de mutar por costumbre
Mutar un objeto o array compartido es una fuente clásica de bugs difíciles de rastrear: algo cambia un dato en un sitio inesperado, y el síntoma aparece tres funciones más allá de la causa. TypeScript te ayuda a hacer cumplir la inmutabilidad en tiempo de compilación, no solo por convención:
interface Usuario {
readonly id: string;
readonly nombre: string;
readonly roles: readonly string[];
}
function agregarRol(usuario: Usuario, rol: string): Usuario {
return {
...usuario,
roles: [...usuario.roles, rol],
};
}
const ana: Usuario = { id: '1', nombre: 'Ana', roles: ['editor'] };
const anaAdmin = agregarRol(ana, 'admin');
// ana.roles sigue siendo ['editor']; anaAdmin.roles es ['editor', 'admin']
readonly en las propiedades y readonly string[] en el array hacen que el propio compilador rechace usuario.roles.push(rol) con un error, no en tiempo de ejecución sino mientras escribes el código. Para estructuras anidadas más complejas, el tipo utilitario Readonly<T> (o as const para literales) cubre buena parte de los casos sin necesitar una librería:
const CONFIG = {
entorno: 'produccion',
limites: { peticiones: 100, ventanaMs: 60_000 },
} as const;
// CONFIG.limites.peticiones = 200; // Error de tipos: es readonly
Cuando necesitas clonar una estructura profundamente anidada sin depender de una librería, structuredClone() —ya disponible de forma nativa tanto en navegadores modernos como en Node.js— resuelve el caso general sin las limitaciones de JSON.parse(JSON.stringify(...)) (que pierde Date, Map, Set y funciones).
Composición: construir funciones grandes a partir de funciones pequeñas
La composición es, probablemente, la idea funcional con mejor relación entre beneficio y esfuerzo de aprendizaje. En vez de escribir una función larga que hace cinco cosas, escribes cinco funciones pequeñas y las encadenas.
type Pedido = { total: number; cuponAplicado: boolean; esClienteVip: boolean };
const aplicarDescuentoCupon = (p: Pedido): Pedido =>
p.cuponAplicado ? { ...p, total: p.total * 0.95 } : p;
const aplicarDescuentoVip = (p: Pedido): Pedido =>
p.esClienteVip ? { ...p, total: p.total * 0.9 } : p;
const redondear = (p: Pedido): Pedido =>
({ ...p, total: Math.round(p.total * 100) / 100 });
Sin una utilidad de composición, encadenar esto obliga a anidar llamadas de dentro hacia fuera (redondear(aplicarDescuentoVip(aplicarDescuentoCupon(pedido)))), que se lee en el orden contrario a como ocurre conceptualmente. Un pipe resuelve exactamente ese problema:
function pipe<A, B>(a: A, ab: (a: A) => B): B;
function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
function pipe(valorInicial: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
return fns.reduce((valor, fn) => fn(valor), valorInicial);
}
const pedidoFinal = pipe(
pedido,
aplicarDescuentoCupon,
aplicarDescuentoVip,
redondear,
);
El pipe se lee de arriba abajo en el mismo orden en que se ejecuta, que es justo el motivo por el que existe. Escribir a mano las sobrecargas de tipos para un pipe genérico de N pasos es tedioso pero mecánico; en la práctica, casi nadie lo reescribe desde cero: Remeda implementa pipe con tipado completo y evaluación perezosa, y libraries como ts-belt o Effect (heredero directo de fp-ts, con quien se fusionó oficialmente) ofrecen lo mismo con distintos niveles de ambición.
Modelar el fallo sin excepciones: cuándo un Result aporta algo real
Uno de los patrones funcionales que más se cita, y más se malinterpreta, es representar un fallo como un valor en vez de lanzar una excepción. La idea de fondo: una excepción es un flujo de control invisible en la firma de la función; un tipo Result<T, E> lo hace explícito.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function parsearEdad(input: string): Result<number, string> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: `"${input}" no es un número válido` };
if (n < 0 || n > 150) return { ok: false, error: `${n} está fuera de rango` };
return { ok: true, value: n };
}
const resultado = parsearEdad(inputUsuario);
if (!resultado.ok) {
mostrarError(resultado.error);
} else {
guardarEdad(resultado.value);
}
La ganancia real aquí es que el compilador te obliga a mirar ambos casos: resultado.value no existe en el tipo hasta que TypeScript ha estrechado (narrowed) la unión comprobando resultado.ok. Con una función que lanza una excepción, el compilador no tiene forma de recordarte que ese catch existe, ni de decirte qué tipo de error esperar.
Esto tiene sentido para errores esperables y parte del dominio: validación de datos de entrada, un pago rechazado, un recurso que no existe. No tiene sentido para errores excepcionales de verdad: un fallo de red inesperado, quedarse sin memoria, un bug de programación. Forzar cada llamada a fetch o cada acceso a base de datos a devolver un Result en vez de dejar que la excepción se propague hasta un manejador centralizado no añade seguridad, añade una capa de boilerplate que hay que desenvolver en cada punto de llamada.
Currying y aplicación parcial: útiles con moderación
El currying —transformar una función de varios argumentos en una cadena de funciones de un argumento— tiene un caso de uso muy concreto en TypeScript: crear variantes especializadas de una función genérica.
const multiplicarPor = (factor: number) => (valor: number): number => valor * factor;
const duplicar = multiplicarPor(2);
const aplicarIVA = multiplicarPor(1.21);
[10, 20, 30].map(duplicar); // [20, 40, 60]
Esto brilla cuando alimentas directamente a .map(), .filter() o un manejador de eventos con una función ya “precargada” con parte de sus argumentos. Donde deja de aportar valor es cuando currificas funciones que casi siempre se llaman con todos sus argumentos a la vez: en ese caso, multiplicarPor(2)(10) es simplemente una forma más difícil de leer de escribir multiplicar(2, 10), sin ningún beneficio de reutilización que lo justifique.
Cuándo la programación funcional aporta claridad, y cuándo es sobreingeniería
La pregunta que de verdad importa, antes de aplicar cualquiera de estos patrones, es: ¿esto hace que la siguiente persona que lea el código entienda mejor qué hace, o le obliga a aprender un vocabulario nuevo para lo mismo?
Aporta claridad real cuando:
- Reemplazas mutación compartida y bugs de estado por transformaciones explícitas de datos.
- Divides una función de cincuenta líneas en varias funciones pequeñas, con nombres que documentan cada paso, encadenadas con
pipeo simplemente por composición directa. - Usas
.map(),.filter(),.reduce()y los iterator helpers en vez de bucles imperativos con variables mutables, porque describen la intención (“transforma”, “filtra”, “acumula”) en vez del mecanismo. - Modelas errores de dominio esperables como valores, en el límite de una capa concreta (validación, parsing), no en todo el árbol de llamadas de la aplicación.
Es sobreingeniería cuando:
- Introduces
Option/Maybepara evitar un simpleif (valor !== undefined), y ahora cada consumidor de esa función necesita saber desenvolver un contenedor genérico para leer un valor que podría haber sidonullsin más ceremonia. - Adoptas una librería de efectos completa (como
Effect) para un script o servicio pequeño dondeasync/awaity untry/catchbien ubicado resuelven el problema con una curva de aprendizaje de cero para el resto del equipo. - El código se vuelve más corto en líneas pero más largo en tiempo de comprensión, porque cada línea exige saber qué hace
flatMap,chainoapen ese contexto concreto. - Currificas o compones por principio, no porque el caso de uso concreto lo pida.
Llevándolo a la práctica
Ninguno de estos patrones exige reescribir un proyecto existente ni adoptar un paradigma nuevo de golpe. La forma realista de introducirlos es incremental: la próxima vez que escribas una función, pregúntate si puede ser pura; la próxima vez que mutes un objeto compartido, pregúntate si una copia inmutable evitaría un bug futuro; la próxima vez que anides tres llamadas de función, prueba si un pipe se lee mejor. Si en algún punto el código empieza a exigir explicar teoría antes de explicar qué hace, has cruzado la línea de vuelta hacia la complejidad que estabas intentando evitar.
Muchas de las funciones nativas de JavaScript que hacen esto posible sin librerías externas —iterator helpers, Object.groupBy, using para gestión de recursos— llegaron al lenguaje relativamente recientes: las repasamos con más detalle en JavaScript moderno en 2026: las funciones que deberías dominar.
Artículos relacionados
Bun vs Node.js vs Deno en 2026: qué runtime elegir y por qué
Tres runtimes de JavaScript, tres filosofías distintas. Esto es lo que dicen los datos (varios, no uno solo) y cuándo cada uno gana de verdad.
TypeScript 6 y el camino a TypeScript 7: la guía de la migración al compilador nativo
TypeScript 6 fue la última versión escrita en TypeScript; TypeScript 7 reescribe el compilador en Go y multiplica la velocidad por diez. Esto es lo que cambia y cómo prepararte.
Gestión de estado en aplicaciones frontend modernas: guía práctica
La pregunta '¿qué librería de estado uso?' suele estar mal planteada. Antes hay que responder otra: ¿qué tipo de estado es este? Local, global o de servidor piden soluciones distintas, y la mitad de las veces la respuesta correcta es no añadir ninguna librería.