Functional programming en TypeScript: lo apliqué en mi codebase real y esto sobrevivió (y esto no)
El 80% del código que se escribe aplicando FP en TypeScript nunca llega a producción. Sí, leíste bien. No porque los conceptos sean malos — sino porque el gap entre un ejemplo con pipe() y una Server Action real con Prisma, efectos secundarios y errores de red es tan grande que la mayoría lo descubre tarde. Yo lo descubrí a las 2am con un deploy roto.
Estaba mirando la playlist de Sahand Javid sobre FP con TypeScript y fp-ts — que el pool de señales marcó como GEM con score 91 — y me entró la energía. "Esto lo puedo aplicar en juanchi.dev ahora mismo." Cuatro horas después tenía código más elegante en dos módulos y un desastre en tres. Esto es lo que aprendí.
Functional programming typescript produccion: qué significa realmente en un stack Next.js 16
Mi tesis, antes de arrancar: FP en TypeScript es poderoso pero tiene un costo de legibilidad que no siempre vale la pena. El secreto no es aplicarlo everywhere — es saber exactamente en qué capas del stack el patrón funcional reemplaza complejidad y en cuáles simplemente la desplaza.
Stack concreto donde hice el experimento: Next.js 16 App Router, TypeScript estricto, Prisma ORM, Server Actions, Railway para infra. No un proyecto de juguete. Un codebase que tiene usuarios reales y que ya me dio un par de sufrimientos memorables (el de la migración a Railway fue uno de los más instructivos).
Qué es fp-ts y por qué importa
fp-ts te da tipos algebraicos de primera clase en TypeScript: Option<A>, Either<E, A>, TaskEither<E, A>, y el operador pipe() para componer funciones sin mutación. La idea es eliminar los efectos secundarios implícitos y hacer que los errores sean valores explícitos del tipo en lugar de excepciones.
Suena fantástico. Y en algunos casos lo es.
Lo que sobrevivió: pipe() y Option en el null handling de Prisma
El primer patrón que adopté y que sigue vivo hoy es Option<A> para manejar queries de Prisma que pueden devolver null.
Antes de fp-ts, tenía esto en varios Server Actions:
// ❌ Antes: null checks dispersos, fácil de olvidar uno
async function obtenerPerfilUsuario(userId: string) {
const usuario = await prisma.usuario.findUnique({ where: { id: userId } });
// ¿Y si alguien agrega un paso acá y se olvida del null check?
if (!usuario) {
return null;
}
const perfil = await prisma.perfil.findUnique({ where: { usuarioId: usuario.id } });
if (!perfil) {
return null;
}
return { usuario, perfil };
}
El problema no es que el código sea feo. El problema es que cada if (!x) return null es un punto donde alguien (yo, en un viernes tarde) puede agregar lógica intermedia y romper el contrato silenciosamente. Lo vi pasar. Me costó un bug que tardé 40 minutos en encontrar.
Con Option y pipe():
import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';
import * as TE from 'fp-ts/TaskEither';
// ✅ Después: la ausencia es un valor explícito en el tipo
const obtenerPerfilUsuario = (userId: string): TE.TaskEither<Error, { usuario: Usuario; perfil: Perfil }> =>
pipe(
// Buscamos el usuario; si no existe, es un Left con error descriptivo
TE.tryCatch(
() => prisma.usuario.findUnique({ where: { id: userId } }),
(e) => new Error(`Error al buscar usuario: ${String(e)}`)
),
TE.flatMap((usuario) =>
usuario
? TE.right(usuario)
: TE.left(new Error(`Usuario ${userId} no encontrado`))
),
// Encadenamos sin perder el contexto de usuario
TE.flatMap((usuario) =>
pipe(
TE.tryCatch(
() => prisma.perfil.findUnique({ where: { usuarioId: usuario.id } }),
(e) => new Error(`Error al buscar perfil: ${String(e)}`)
),
TE.flatMap((perfil) =>
perfil
? TE.right({ usuario, perfil })
: TE.left(new Error(`Perfil para ${usuario.id} no encontrado`))
)
)
)
);
¿Es más verboso? Sí, bastante. ¿Vale la pena? En esta capa específica, sí. El tipo de retorno TaskEither<Error, {...}> le dice al compilador — y a cualquier dev del equipo — que esta función puede fallar y que el error es un valor que hay que manejar. No podés ignorarlo.
Lo que me convenció del todo: cuando integré este patrón con TypeScript estricto y tipos que se propagan, el compilador me empezó a gritar en los consumidores de la función. Antes, los null se filtraban silenciosos hasta el render.
Veredicto: sobrevivió. Lo uso en todas las queries de Prisma que tienen más de un step dependiente.
Lo que no sobrevivió: Either para manejo de errores en Server Actions con side effects
Acá viene la parte incómoda. Y la cuento porque nadie la documenta.
Intenté reemplazar los try/catch de mis Server Actions con Either<Error, T>. La promesa era hermosa: errores como valores, composición limpia, tipos que te protegen. Duró dos semanas.
El problema concreto: las Server Actions de Next.js 16 no viven en un mundo funcional puro. Tienen side effects por todos lados — logs, revalidaciones de caché, eventos de analytics, mutaciones de estado externo. Y cuando intentás meter Either en ese contexto, el código se convierte en esto:
// ❌ Esto pareció buena idea por 11 días
import * as E from 'fp-ts/Either';
async function crearPublicacion(data: NuevaPublicacion): Promise<E.Either<string, Publicacion>> {
// Validación
const validacion = validarPublicacion(data);
if (E.isLeft(validacion)) {
// Loguear el error — primer side effect que rompe la pureza
await logger.error('Validacion fallida', E.getLeft(validacion));
return validacion;
}
// Guardar en DB
const resultado = await E.tryCatch(
() => prisma.publicacion.create({ data: E.getRight(validacion) as NuevaPublicacion }),
String
);
if (E.isLeft(resultado)) {
// Segundo side effect: revalidar igual aunque falló
revalidatePath('/blog');
return resultado;
}
// Tercer side effect: notificación
await notificarSuscriptores(E.getRight(resultado) as Publicacion);
// Cuarto side effect: revalidar caché
revalidatePath('/blog');
// Y acá me di cuenta: esto es un try/catch con más ceremonia
return resultado;
}
Después de dos semanas tenía un Either que envolvía cuatro side effects, y cada consumidor tenía que hacer E.isLeft() + E.getRight() para acceder al valor. El type safety era real, pero el costo cognitivo para el equipo era mayor que el beneficio.
Lo revertí. No con vergüenza — con claridad.
// ✅ La versión que sobrevivió: try/catch honesto + tipo de retorno explícito
type ResultadoAccion<T> =
| { ok: true; data: T }
| { ok: false; error: string; code?: string };
async function crearPublicacion(data: NuevaPublicacion): Promise<ResultadoAccion<Publicacion>> {
try {
const publicacion = await prisma.publicacion.create({ data });
await notificarSuscriptores(publicacion);
revalidatePath('/blog');
return { ok: true, data: publicacion };
} catch (error) {
logger.error('Error al crear publicacion', error);
return { ok: false, error: 'No se pudo crear la publicación', code: 'DB_ERROR' };
}
}
Es más corto. Es más legible. Y el tipo ResultadoAccion<T> sigue siendo discriminado — TypeScript te obliga a checar ok antes de acceder a data. Tengo el 80% del beneficio con el 20% del costo.
Veredicto: no sobrevivió. Either en Server Actions con side effects es más ceremonia que protección.
Los gotchas que nadie te avisa antes de tirarte a fp-ts
1. El type inference de TypeScript con tipos fp-ts se pone raro bajo presión
Con TypeScript 7 beta, los types de fp-ts a veces generan inferencia que el compilador resuelve con un tipo intermedio que no esperás. Tuve casos donde el tipo inferido era TaskEither<unknown, unknown> porque una función en el pipe no tenía anotación explícita. Resultado: el compilador no te avisa del error hasta que intentás consumir el resultado.
La solución: anotar explícitamente los tipos de retorno en cada step del pipe cuando usás fp-ts. No confíes en la inferencia para cadenas largas.
// ❌ Inferencia que te traiciona en cadenas largas
const resultado = pipe(
buscarUsuario(id), // TaskEither<Error, Usuario>
TE.flatMap(transformar), // ← si 'transformar' no está anotada, puede inferirse mal
TE.map(formatear)
);
// ✅ Con anotaciones explícitas donde hay ambigüedad
const resultado: TE.TaskEither<Error, UsuarioFormateado> = pipe(
buscarUsuario(id),
TE.flatMap((u): TE.TaskEither<Error, UsuarioTransformado> => transformar(u)),
TE.map(formatear)
);
2. pipe() con más de 6 steps es ilegible en revisión de código
Esto lo aprendí doloroso. Tenía un pipe con 8 steps para procesar un payload de webhook. En code review, mi compañero de equipo tardó 20 minutos en entender qué hacía. El mismo código con funciones con nombres descriptivos y tres await era inmediatamente claro.
Regla que adopté: si el pipe supera 5 steps, nombrá las transformaciones intermedias como funciones separadas.
3. fp-ts en el bundle del cliente: cuidado con Next.js App Router
Si importás fp-ts en un componente que termina en el bundle del cliente, el peso adicional es no trivial. En mi caso, fp-ts completo son ~70KB sin minificar. Lo descubrí analizando el bundle con @next/bundle-analyzer. La solución fue simple: fp-ts sólo en Server Actions y utilidades de servidor. Nunca en componentes de cliente. Relacionado con algunos de los patrones de seguridad que aplico al revisar dependencias en producción.
4. Onboarding del equipo: el costo real que los tutoriales ignoran
Si estás en un equipo de más de una persona, cada pattern nuevo de fp-ts es tiempo de onboarding. TaskEither, flatMap, fold — son conceptos que requieren contexto teórico para no verse como magia. En Lakaut Hub, tuve que escribir una guía interna de dos páginas solo para explicar por qué pipe(TE.tryCatch(...), TE.map(...)) era equivalente a lo que antes hacíamos con try/catch. El beneficio tiene que ser lo suficientemente claro para justificar ese costo.
FAQ: Functional programming en TypeScript en producción
¿Necesito usar fp-ts para hacer functional programming en TypeScript?
No. Podés aplicar principios de FP — funciones puras, inmutabilidad, composición — sin instalar nada. fp-ts da tipos algebraicos bien implementados, pero si no estás en un equipo con contexto teórico, empezá por funciones puras y pipe() de lodash/fp o incluso una implementación propia de 5 líneas. El 80% del valor de FP viene de los principios, no de la librería.
¿Cuándo tiene sentido usar Option<A> en vez de T | null?
Cuando la ausencia del valor necesita propagarse a través de múltiples transformaciones sin que cada step tenga que checar explícitamente. En queries de Prisma con cadenas de dependencias, Option o TaskEither eliminan los null checks intermedios. En un formulario simple que puede devolver null, T | null con un if es suficiente y más legible.
¿fp-ts se lleva bien con Prisma y los tipos generados?
Con fricción. Los tipos de Prisma son interfaces que asumen mutabilidad y no son "functor-friendly" por naturaleza. La integración funciona, pero necesitás wrappers explícitos. TE.tryCatch(() => prisma.xxx.findUnique(...), toError) es el patrón estándar. Nada mágico.
¿FP en TypeScript afecta el performance en producción? En mi stack, no de forma medible. La diferencia está en el bundle size si importás fp-ts en el cliente (evitalo) y en el tiempo de compilación con cadenas de tipos complejas (real pero menor). El overhead de runtime de pipe() y los tipos algebraicos es negligible comparado con una query a Postgres.
¿Qué patrón de FP recomendarías para alguien que arranca?
Primero: pipe() para componer funciones sin variables intermedias innecesarias. Segundo: funciones puras para transformaciones de datos. Tercero, recién cuando estés cómodo: Option/Either para manejar ausencia y errores como valores. En ese orden. No al revés.
¿Vale la pena el costo de aprender fp-ts si ya sé TypeScript bien? Depende del tipo de código que escribís. Si trabajás con transformaciones de datos complejas, pipelines de procesamiento o dominios donde los errores son valores de negocio (no excepciones), sí. Si tu codebase principal son CRUD con Next.js y Server Actions, el ROI es bajo. Yo uso fp-ts en ~30% del codebase — exactamente donde los tipos algebraicos dan ventaja real.
Lo incómodo que nadie dice sobre FP en producción TypeScript
Mi postura final: FP en TypeScript es una herramienta de precisión, no una filosofía de codebase. La playlist de Sahand Javid que inició este experimento es excelente — los conceptos están bien explicados, los ejemplos son claros. El problema es que los ejemplos claros viven en un mundo sin side effects, sin Next.js revalidation, sin logs de producción y sin compañeros de equipo que ven un fold() por primera vez a las 4pm del viernes.
Lo que sobrevivió en mi stack: Option para null handling en cadenas Prisma, TaskEither para operaciones async con errores de negocio bien definidos, pipe() para composición de transformaciones de datos puras. Lo que no sobrevivió: Either en Server Actions con side effects, pipes de más de 5 steps sin nombres intermedios, fp-ts en el bundle del cliente.
El patrón que aplico hoy: arranco con TypeScript estricto y discriminated unions propias ({ ok: true; data: T } | { ok: false; error: string }). Cuando una cadena de transformaciones empieza a acumular null checks o el manejo de errores se vuelve verboso, ahí introduzco fp-ts específicamente. No antes.
Es la misma lógica que uso cuando evalúo cualquier abstracción nueva — sea un nuevo pattern de seguridad para dependencias o un rediseño de arquitectura de agentes: ¿reemplaza complejidad real o simplemente la mueve de lugar?
En FP con TypeScript, la respuesta honesta es: depende exactamente de dónde lo aplicás.
Si estás intentando meter fp-ts en algún codebase real y te encontrás con algún gotcha que no cubrí acá, mandame el snippet. Me interesa.
Fuente original:
- Playlist curated de Functional Programming con TypeScript y fp-ts — Sahand Javid: https://www.youtube.com/playlist?list=PLuPevXgCPUIMbmgUSky9Y9MAQFH0KLUF0
Artículos Relacionados
Themis vs Web Crypto API: probé ambas para cifrado en mi app de identidad digital y los tradeoffs no son obvios
Estoy construyendo Lakaut ID, un sistema de validación de identidad biométrica. La criptografía no es un ejercicio académico: es el corazón del producto. Comparé Themis con Web Crypto API en casos concretos y los tradeoffs me sorprendieron.
Spring Boot en producción real: lo que mi codebase de Lakaut me enseñó que la documentación oficial omite
Tres años laburando con Spring Boot en producción real en Lakaut AC me dejaron logs, incidentes y métricas que ningún tutorial va a mostrarte. Los defaults de la doc oficial asumen un entorno que no existe en Railway con JVM tuning real.
Clipboard API falla en TypeScript: los 4 casos que nadie documenta y cómo los encontré en mi código
navigator.clipboard.writeText parece trivial hasta que tu app falla en producción sin error visible. Encontré 4 casos que los docs no mencionan: contexto inseguro, foco perdido, permisos revocados en iOS y el timing de React. Acá están los patrones reales con código copiable.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.