Programación funcional en JavaScript: composición, currying, pipe y funciones puras

La programación funcional en JavaScript no requiere un cambio radical de paradigma ni adoptar una biblioteca especializada. Con ES6+ se pueden aplicar sus principios fundamentales —funciones puras, inmutabilidad, composición y currying— de forma gradual, mejorando la testabilidad y la legibilidad del código.

Funciones puras: sin efectos secundarios

Una función pura siempre devuelve el mismo resultado para los mismos argumentos y no modifica nada fuera de su ámbito. Son predecibles, fáciles de testear y se pueden memorizar:

// Impura — depende de estado externo y modifica un array
const historial = [];
function impura_registrar(accion) {
  historial.push(accion);           // Efecto secundario
  return new Date().getTime();      // Resultado no determinista
}

// Pura — mismo input ? mismo output, sin efectos
function calcularDescuento(precio, porcentaje) {
  if (precio < 0 || porcentaje < 0 || porcentaje > 100) {
    throw new RangeError('Parámetros inválidos');
  }
  return precio * (1 - porcentaje / 100);
}

// Fácil de testear:
console.log(calcularDescuento(100, 20)); // 80 — siempre
console.log(calcularDescuento(100, 20)); // 80 — siempre

Inmutabilidad con spread operator

Trabajar con datos inmutables evita bugs difíciles de rastrear causados por mutaciones inesperadas. El spread operator y los métodos inmutables de array facilitan crear nuevas versiones sin tocar el original:

// Actualizar un elemento en un array sin mutarlo
function actualizarItem(lista, id, cambios) {
  return lista.map(item =>
    item.id === id ? { ...item, ...cambios } : item
  );
}

// Añadir un elemento
function añadir(lista, item) {
  return [...lista, item];
}

// Eliminar un elemento
function eliminar(lista, id) {
  return lista.filter(item => item.id !== id);
}

const tareas = [
  { id: 1, texto: 'Diseño', completada: false },
  { id: 2, texto: 'Desarrollo', completada: false },
];

const actualizadas = actualizarItem(tareas, 1, { completada: true });
console.log(tareas[0].completada);       // false — sin cambios
console.log(actualizadas[0].completada); // true

Currying genérico

El currying transforma una función de múltiples argumentos en una cadena de funciones que aceptan un argumento cada una. Permite crear versiones especializadas de funciones reutilizables:

// Curry manual para 2 y 3 argumentos
const curry2 = fn => a => b => fn(a, b);
const curry3 = fn => a => b => c => fn(a, b, c);

// Curry genérico para cualquier número de argumentos
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return function(...masArgs) {
      return curried(...args, ...masArgs);
    };
  };
}

// Ejemplo de uso
const sumar = curry((a, b) => a + b);
const sumar5 = sumar(5);
console.log(sumar5(3));  // 8
console.log(sumar5(10)); // 15

const filtrarPorProp = curry((prop, valor, array) =>
  array.filter(item => item[prop] === valor)
);

const soloActivos = filtrarPorProp('activo', true);
const usuarios = [
  { nombre: 'Ana', activo: true },
  { nombre: 'Bob', activo: false },
  { nombre: 'Carlos', activo: true },
];
console.log(soloActivos(usuarios).map(u => u.nombre)); // ['Ana', 'Carlos']

Composición con pipe y compose

pipe encadena funciones de izquierda a derecha (como las tuberías Unix); compose lo hace de derecha a izquierda (orden matemático). Ambas aplican cada función al resultado de la anterior:

const pipe = (...fns) => valor => fns.reduce((acc, fn) => fn(acc), valor);
const compose = (...fns) => valor => fns.reduceRight((acc, fn) => fn(acc), valor);

// Transformaciones reutilizables
const limpiar = str => str.trim();
const minusculas = str => str.toLowerCase();
const sinEspacios = str => str.replace(/s+/g, '-');
const sinAcentos = str => str.normalize('NFD').replace(/[?-?]/g, '');

const crearSlug = pipe(limpiar, minusculas, sinAcentos, sinEspacios);

console.log(crearSlug('  Programación Funcional en JavaScript  '));
// 'programacion-funcional-en-javascript'

// Transformar un array de usuarios
const procesarUsuarios = pipe(
  usuarios => usuarios.filter(u => u.activo),
  usuarios => usuarios.map(u => ({ ...u, nombre: u.nombre.toUpperCase() })),
  usuarios => usuarios.sort((a, b) => a.nombre.localeCompare(b.nombre))
);

const resultado = procesarUsuarios([
  { nombre: 'carlos', activo: true },
  { nombre: 'ana', activo: false },
  { nombre: 'beatriz', activo: true },
]);
// [{ nombre: 'BEATRIZ', activo: true }, { nombre: 'CARLOS', activo: true }]

Memoización

La memoización cachea el resultado de una función pura para evitar recalcular operaciones costosas con los mismos argumentos:

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const clave = JSON.stringify(args);
    if (cache.has(clave)) {
      return cache.get(clave);
    }
    const resultado = fn(...args);
    cache.set(clave, resultado);
    return resultado;
  };
}

const fibonacci = memoize(function fib(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
});

console.time('fib');
console.log(fibonacci(40)); // 102334155
console.timeEnd('fib');     // < 1ms gracias al cache

El patrón Maybe: eliminar null checks

El patrón Maybe (o Option) envuelve un valor que puede ser null/undefined y permite encadenar operaciones sin comprobar null en cada paso:

class Maybe {
  constructor(valor) {
    this._valor = valor;
  }

  static of(valor) {
    return new Maybe(valor);
  }

  static vacio() {
    return new Maybe(null);
  }

  esVacio() {
    return this._valor === null || this._valor === undefined;
  }

  map(fn) {
    return this.esVacio() ? Maybe.vacio() : Maybe.of(fn(this._valor));
  }

  getOrElse(porDefecto) {
    return this.esVacio() ? porDefecto : this._valor;
  }
}

// Sin Maybe — lleno de null checks
function obtenerCiudad(usuario) {
  if (!usuario) return 'Desconocida';
  if (!usuario.direccion) return 'Desconocida';
  if (!usuario.direccion.ciudad) return 'Desconocida';
  return usuario.direccion.ciudad;
}

// Con Maybe — encadenado y legible
const obtenerCiudadM = usuario =>
  Maybe.of(usuario)
    .map(u => u.direccion)
    .map(d => d.ciudad)
    .getOrElse('Desconocida');

console.log(obtenerCiudadM(null));                               // 'Desconocida'
console.log(obtenerCiudadM({ nombre: 'Ana' }));                  // 'Desconocida'
console.log(obtenerCiudadM({ direccion: { ciudad: 'Madrid' } })); // 'Madrid'

Antipatrones habituales al escribir código funcional

Algunos errores frecuentes al intentar escribir código funcional en JavaScript:

// MALO: map con efectos secundarios
const efectos = [];
[1, 2, 3].map(n => { efectos.push(n * 2); return n; }); // Usar forEach

// MALO: reduce que acumula mutando
const resultadoMal = [1, 2, 3].reduce((acc, n) => {
  acc.push(n * 2); // Muta el acumulador
  return acc;
}, []);

// BIEN: spread para inmutabilidad
const resultadoBien = [1, 2, 3].reduce((acc, n) => [...acc, n * 2], []);

// MALO: pasar métodos de objeto como callbacks sin bind
const obj = { multiplicador: 3, calcular(n) { return n * this.multiplicador; } };
[1, 2, 3].map(obj.calcular); // this es undefined — NaN

// BIEN: usar arrow function o bind
[1, 2, 3].map(n => obj.calcular(n));
[1, 2, 3].map(obj.calcular.bind(obj));

La programación funcional en JavaScript no es un todo o nada. Aplicar funciones puras donde tiene sentido, evitar mutaciones en transformaciones de datos y usar composición para combinar transformaciones simples en pipelines legibles mejora significativamente la calidad del código sin necesidad de adoptar ninguna biblioteca ni cambiar toda la arquitectura.

COMPARTE ESTE ARTÍCULO

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