Saltar al contenido

Patrones de programación funcional en TypeScript

Patrones prácticos de programación funcional en TypeScript que mejoran la claridad del código sin exigir un cambio total de paradigma.

5 min de lectura
Código TypeScript que muestra funciones compose y pipe con anotaciones de tipos

La programación funcional en TypeScript no significa abandonar las clases, reescribir todo con monadas ni adoptar una filosofía purista. Significa aplicar patrones concretos — inmutabilidad, funciones puras, composición — donde reduzcan la complejidad y mejoren la testabilidad. Estos patrones coexisten de forma natural con el código orientado a objetos. El objetivo es el pragmatismo, no la ideología.

Los patrones que aportan más valor en el TypeScript cotidiano son más sencillos de lo que crees.

Funciones puras

Una función pura devuelve la misma salida para la misma entrada y no produce efectos secundarios. Esta única propiedad hace que una función sea trivialmente testeable, segura para paralelizar y fácil de razonar.

tstypescript
// ❌ Impure — depends on external state, mutates input
let taxRate = 0.08;
function calculateTotal(items: CartItem[]) {
  let total = 0;
  items.forEach(item => {
    item.totalPrice = item.price * item.quantity; // mutation
    total += item.totalPrice;
  });
  return total * (1 + taxRate); // depends on external variable
}
 
// ✅ Pure — all inputs explicit, no mutation, no side effects
function calculateTotal(items: readonly CartItem[], taxRate: number): number {
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  return subtotal * (1 + taxRate);
}

No toda función puede ser pura: las peticiones HTTP, las llamadas a base de datos y el logging son inherentemente impuros. La estrategia consiste en empujar las operaciones impuras hacia los bordes del sistema y mantener la lógica central pura.

Inmutabilidad por defecto

La mutación es la causa raíz de toda una clase de errores: closures obsoletos, corrupción de estado compartido, problemas de renderizado en React, condiciones de carrera. TypeScript proporciona readonly para evitar la mutación a nivel de tipos.

tstypescript
// ❌ Mutation — callers don't expect their data to change
function addDiscount(order: Order): Order {
  order.total *= 0.9;  // mutates the original
  order.discountApplied = true;
  return order;
}
 
// ✅ Immutable — returns a new object
function addDiscount(order: Readonly<Order>): Order {
  return {
    ...order,
    total: order.total * 0.9,
    discountApplied: true,
  };
}

Usa readonly en los parámetros de función para imponer la inmutabilidad:

tstypescript
// ReadonlyArray prevents push, pop, splice
function getTopScores(scores: readonly number[], limit: number): number[] {
  // scores.sort() would error — readonly
  return [...scores].sort((a, b) => b - a).slice(0, limit);
}
 
// Readonly<T> makes all properties readonly
interface Config {
  host: string;
  port: number;
  features: string[];
}
 
function createServer(config: Readonly<Config>): Server {
  // config.port = 8080; // Error: readonly
  return new Server({ ...config });
}

Composición de funciones

La composición construye operaciones complejas a partir de funciones simples y reutilizables. En lugar de una única función que hace cinco cosas, compones cinco funciones que cada una hace una cosa.

tstypescript
// Pipe: left-to-right composition
function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
  return (arg: T) => fns.reduce((result, fn) => fn(result), arg);
}
 
// Individual transformation functions
const trim = (s: string) => s.trim();
const lowercase = (s: string) => s.toLowerCase();
const slugify = (s: string) => s.replace(/\s+/g, '-');
const removeSpecialChars = (s: string) => s.replace(/[^a-z0-9-]/g, '');
 
// Compose them into a pipeline
const toSlug = pipe(trim, lowercase, slugify, removeSpecialChars);
 
toSlug("  Hello World! 123  "); // "hello-world-123"

Para tipos heterogéneos, TypeScript no soporta bien los genéricos variádicos en un pipe genérico. Usa cadenas explícitas o librerías:

tstypescript
// Array processing pipeline
function processUsers(users: User[]): UserSummary[] {
  return users
    .filter(isActive)
    .filter(hasVerifiedEmail)
    .map(toUserSummary)
    .sort(byLastLogin);
}
 
// Each function is pure, testable independently
const isActive = (user: User): boolean => user.status === 'active';
const hasVerifiedEmail = (user: User): boolean => user.emailVerified;
const toUserSummary = (user: User): UserSummary => ({
  id: user.id,
  name: user.name,
  lastLogin: user.lastLoginAt,
});
const byLastLogin = (a: UserSummary, b: UserSummary): number =>
  b.lastLogin.getTime() - a.lastLogin.getTime();

Tipos Option/Result para el manejo de errores

En lugar de lanzar excepciones para fallos esperados, usa tipos resultado que obligan a quien llama a manejar tanto el éxito como el fracaso.

tstypescript
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}
 
function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}
 
// Usage — caller must handle both cases
function parseConfig(raw: string): Result<Config, string> {
  try {
    const parsed = JSON.parse(raw);
    if (!parsed.host || !parsed.port) {
      return err('Missing required fields: host, port');
    }
    return ok({ host: parsed.host, port: parsed.port });
  } catch {
    return err('Invalid JSON');
  }
}
 
const result = parseConfig(configString);
if (result.ok) {
  startServer(result.value); // TypeScript knows value exists
} else {
  console.error(result.error); // TypeScript knows error exists
}
tstypescript
// ❌ Exception-based — nothing forces callers to handle errors
function findUser(id: string): User {
  const user = db.find(id);
  if (!user) throw new NotFoundError('User not found');
  return user;
}
// Caller might forget try/catch — runtime explosion
 
// ✅ Result-based — type system enforces error handling
function findUser(id: string): Result<User, 'not-found'> {
  const user = db.find(id);
  if (!user) return err('not-found');
  return ok(user);
}
// Caller can't access .value without checking .ok first

Funciones de orden superior

Funciones que reciben funciones como argumentos o devuelven funciones. Ya las usas: map, filter y reduce son funciones de orden superior.

tstypescript
// Factory function — returns configured function
function createValidator<T>(rules: Array<(value: T) => string | null>) {
  return (value: T): string[] => {
    return rules
      .map(rule => rule(value))
      .filter((error): error is string => error !== null);
  };
}
 
const validatePassword = createValidator<string>([
  (pw) => pw.length < 8 ? 'Must be at least 8 characters' : null,
  (pw) => !/[A-Z]/.test(pw) ? 'Must contain uppercase letter' : null,
  (pw) => !/[0-9]/.test(pw) ? 'Must contain a number' : null,
]);
 
validatePassword('weak');       // ['Must be at least 8 characters', 'Must contain uppercase letter', 'Must contain a number']
validatePassword('StrongPass1'); // []

Memoización

tstypescript
function memoize<T extends (...args: any[]) => any>(fn: T): T {
  const cache = new Map<string, ReturnType<T>>();
 
  return ((...args: Parameters<T>): ReturnType<T> => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key)!;
 
    const result = fn(...args);
    cache.set(key, result);
    return result;
  }) as T;
}
 
// Expensive computation — memoize it
const computeLayout = memoize((width: number, items: number): Layout => {
  // ... complex layout calculation
  return { columns: Math.floor(width / 300), rows: Math.ceil(items / 3) };
});

La memoización solo funciona para funciones puras. Si la función depende del estado externo, el resultado cacheado puede quedar obsoleto.

Refactorización funcional práctica

Estos patrones brillan al refactorizar código procedural complejo. La diferencia entre el antes y el después es dramática.

tstypescript
// ❌ Procedural — hard to test, hard to modify
async function processOrders(orders: Order[]): Report {
  const report: Report = { total: 0, byStatus: {}, errors: [] };
 
  for (const order of orders) {
    try {
      if (!order.items?.length) {
        report.errors.push(`Order ${order.id}: no items`);
        continue;
      }
      const total = order.items.reduce((s, i) => s + i.price * i.qty, 0);
      const tax = total * 0.08;
      const status = total > 1000 ? 'high-value' : 'standard';
      report.total += total + tax;
      report.byStatus[status] = (report.byStatus[status] ?? 0) + 1;
    } catch (e) {
      report.errors.push(`Order ${order.id}: ${e}`);
    }
  }
  return report;
}
 
// ✅ Functional — each step is independently testable
const calculateOrderTotal = (order: Order): number =>
  order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
 
const applyTax = (rate: number) => (amount: number): number =>
  amount * (1 + rate);
 
const classifyOrder = (total: number): string =>
  total > 1000 ? 'high-value' : 'standard';
 
const validateOrder = (order: Order): Result<Order, string> =>
  order.items?.length ? ok(order) : err(`Order ${order.id}: no items`);
 
function processOrders(orders: Order[]): Report {
  const results = orders.map(order => {
    const validation = validateOrder(order);
    if (!validation.ok) return { error: validation.error };
 
    const total = applyTax(0.08)(calculateOrderTotal(order));
    return { total, status: classifyOrder(total) };
  });
 
  return {
    total: results.reduce((s, r) => s + ('total' in r ? r.total : 0), 0),
    byStatus: results.reduce((acc, r) => {
      if ('status' in r) acc[r.status] = (acc[r.status] ?? 0) + 1;
      return acc;
    }, {} as Record<string, number>),
    errors: results.filter(r => 'error' in r).map(r => (r as any).error),
  };
}

Conclusiones clave

  1. Las funciones puras son el patrón con más palanca — la ausencia de efectos secundarios significa testing trivial y refactorización segura
  2. Inmutabilidad por defecto — usa readonly y operadores spread. La mutación es una optimización, no un valor por defecto.
  3. Composición frente a complejidad — construye pipelines a partir de funciones pequeñas en lugar de procedimientos monolíticos
  4. Los tipos Result reemplazan a las excepciones para fallos esperados — el sistema de tipos impone el manejo de errores
  5. Las funciones de orden superior reducen la duplicación — las factories y los decoradores crean comportamiento configurado sin repetición
  6. Aplícalos incrementalmente — no necesitas reescribirlo todo. Empieza con funciones de utilidad puras y compón a partir de ahí
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX