🟨 JavaScript & TypeScript

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.

📅 19 de septiembre de 2026 ⏱️ 11 min de lectura ✍️ Equipo ProgramacionWebs

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 pipe o 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/Maybe para evitar un simple if (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 sido null sin más ceremonia.
  • Adoptas una librería de efectos completa (como Effect) para un script o servicio pequeño donde async/await y un try/catch bien 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, chain o ap en 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.

Compartir