DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

¿De verdad necesitás fp-ts, o te alcanza un union nativo?

Esta semana escribí sobre functional programming con TypeScript y lo que fp-ts enseña. Quedé conforme con lo que expliqué, pero mientras armaba los ejemplos de Either y Option se me metió una pregunta que no me dejó tranquilo: ¿todo ese vocabulario nuevo — pipe, chain, fold, TaskEither — resuelve algo que TypeScript no resuelve solo?

Mi tesis, sin vueltas: fp-ts es una herramienta potente pero es sobreingeniería en la mayoría de los codebases de equipos que no vienen de Haskell o Scala. Un discriminated union bien tipado, de los que ya trae el lenguaje, alcanza para el problema que casi todos los que instalan fp-ts están tratando de resolver: manejar errores sin excepciones y sin nulls sueltos dando vueltas.

No es una retractación del post anterior. Es la mitad que faltaba: cuándo pagar la curva vale la pena, y cuándo es plata tirada en abstracción.

El problema real detrás de fp-ts

El dolor que trae a la gente a Either<E, A> no es "quiero programación funcional". Es más chico y más concreto: una función puede fallar, y quiero que el compilador me obligue a manejar ese fallo antes de tocar el resultado. Nada de try/catch que se olvida, nada de null que se filtra tres capas más arriba.

Ese problema tiene una solución nativa en TypeScript que no necesita ninguna librería: el discriminated union. Está documentado en el TypeScript Handbook, sección de narrowing, y ahí el lenguaje explica exactamente esto — cómo un campo literal común permite que el compilador angoste el tipo dentro de un if o un switch sin ninguna abstracción adicional.

Lo que el Handbook no dice, porque no es su trabajo, es cuándo ese patrón deja de ser suficiente. Esa parte la tenés que resolver con criterio, no con la documentación.

Union nativo vs Either: el mismo problema, dos costos distintos

Con fp-ts, una función que puede fallar se ve así:

import { Either, left, right } from 'fp-ts/Either';

function dividir(a: number, b: number): Either<string, number> {
  if (b === 0) return left('division por cero');
  return right(a / b);
}
Enter fullscreen mode Exit fullscreen mode

Para consumir ese resultado necesitás pipe, fold o match, y entender que Either es un funtor con dos casos. Nada de esto es difícil una vez que lo internalizaste. El costo no es la dificultad puntual: es que cada dev nuevo en el equipo tiene que internalizarlo antes de poder leer el código con soltura.

La alternativa nativa, con discriminated union:

type Resultado<T> =
  | { ok: true; valor: T }
  | { ok: false; error: string };

function dividir(a: number, b: number): Resultado<number> {
  if (b === 0) return { ok: false, error: 'division por cero' };
  return { ok: true, valor: a / b };
}

const r = dividir(10, 2);
if (r.ok) {
  console.log(r.valor); // TypeScript sabe que existe "valor" aca
} else {
  console.log(r.error); // y aca sabe que existe "error"
}
Enter fullscreen mode Exit fullscreen mode

El compilador angosta el tipo solo con el if (r.ok). No hay que importar nada, no hay que explicarle a nadie qué es un funtor, y cualquiera que haya visto un switch en su vida entiende el flujo en diez segundos. Es el mismo mecanismo que ya usé para modelar el resultado de firmar un documento en CAdES vs XAdES en Java: dos formas válidas, un campo discriminante, cero ambigüedad.

Dónde se equivoca la gente con fp-ts

La receta típica que veo — y que yo mismo seguí antes de frenar a pensarlo — es: "vi un video de fp-ts, se ve prolijo, lo meto en el proyecto". El costo oculto aparece tres sprints después, cuando alguien del equipo que nunca tocó programación funcional tiene que debuggear un pipe de seis pasos con chain anidados y no tiene ni el vocabulario para googlear el error.

El contraejemplo que sí justifica la curva: componer varias operaciones que pueden fallar en cadena, donde cada paso depende del anterior y necesitás que el error se propague automáticamente sin escribir un if (!r.ok) return r después de cada línea. Ahí chain no es decoración, es lo que evita el código repetido. Si tenés cinco validaciones encadenadas y cada una devuelve un discriminated union, terminás escribiendo el mismo chequeo cinco veces. Con Either y pipe, lo escribís una sola vez y se aplica a toda la cadena.

Ese mismo patrón de "la abstracción se justifica cuando el volumen de repetición lo pide, no antes" es el que discutí con Virtual Threads en Java: la concurrencia liviana no te salva de un synchronized mal puesto — la herramienta nueva resuelve un problema puntual, no todos los problemas adyacentes.

Matriz de decisión: cuándo pagar la curva y cuándo no

Situación Discriminated union nativo fp-ts (Either/Option)
Una función, un posible fallo, se consume una vez Alcanza y sobra Sobreingeniería
Encadenar 4+ operaciones que pueden fallar en secuencia Se vuelve repetitivo Ahí chain/pipe gana
Equipo sin experiencia en FP, rotación alta Se lee sin explicación previa Cada onboarding cuesta tiempo
Necesitás componer con Promise y error tipado a la vez Hay que armarlo a mano TaskEither ya lo resuelve
El código lo va a tocar gente de otros equipos ocasionalmente Menor barrera de entrada Barrera de entrada real
Ya tenés una base de código funcional consistente Rompe la consistencia Se integra natural

Esta tabla no es una conclusión cerrada — es un punto de partida para decidir, caso por caso, si el problema que tenés adelante es "una función que falla" o "un pipeline de fallos compuestos". La diferencia entre esas dos cosas es la diferencia entre necesitar fp-ts y no necesitarlo.

Un criterio corto que uso para no pensarlo de cero cada vez: si puedo escribir el manejo de error completo en menos de cinco líneas con un if, no abro la carpeta de fp-ts. Si ese if se repite más de tres veces en el mismo archivo, ahí empiezo a mirar pipe.

flowchart LR
  A[Función que puede fallar] --> B{¿Se encadena con otras que también fallan?}
  B -->|No, es un caso aislado| C[Discriminated union nativo]
  B -->|Sí, 4+ pasos dependientes| D{¿El equipo ya conoce FP?}
  D -->|No| E[Union nativo + función helper propia]
  D -->|Sí| F[fp-ts: Either + pipe/chain]
Enter fullscreen mode Exit fullscreen mode

Los límites de esta comparación

No tengo benchmarks de tiempo de onboarding ni métricas de bugs evitados por uno u otro enfoque — y si los viera publicados, tampoco confiaría en ellos sin conocer la metodología. Lo que hay acá es un criterio de diseño, apoyado en cómo TypeScript documenta oficialmente el narrowing con discriminated unions, no un experimento controlado.

Tampoco es un veto a fp-ts. Es una herramienta real, con una comunidad seria detrás, y en proyectos donde ya se adoptó de punta a punta cambiar de rumbo a mitad de camino sería peor que la curva de aprendizaje original. La decisión de adoptarla se toma al principio del proyecto, no en el archivo que estás tocando hoy.

Si el equipo ya viene de Scala o Haskell, el cálculo cambia por completo: para esas personas fp-ts no es una curva, es el idioma que ya hablan. Este análisis está pensado para el caso más común en TypeScript: equipos que aprendieron el lenguaje viniendo de JavaScript, no de un lenguaje funcional puro.

FAQ

¿Either de fp-ts hace algo que un discriminated union no puede hacer?
Para el caso simple, no. Para componer cadenas largas de operaciones falibles con propagación automática de errores, chain y pipe evitan repetir el chequeo manual en cada paso. Ahí sí hay una diferencia funcional, no solo estética.

¿Vale la pena aprender fp-ts si nunca usé un lenguaje funcional?
Si el proyecto ya lo usa, sí, porque la alternativa es leer código que no entendés. Si estás arrancando de cero con un equipo sin background funcional, el criterio prudente es al revés: medí primero si el problema real justifica la abstracción, o si un union nativo te cubre la mayoría de los casos sin pedirle a nadie que aprenda vocabulario nuevo.

¿Option de fp-ts reemplaza a T | null?
En el nivel conceptual, sí — Option<T> es Some<T> | None, muy parecido a T | null pero con métodos de composición. La diferencia práctica es que con T | undefined TypeScript ya te obliga a chequear con strict mode activado, sin instalar nada.

¿El discriminated union nativo tiene alguna desventaja real frente a Either?
Sí: no tiene combinadores. Si necesitás mapear, encadenar o combinar varios resultados falibles de forma genérica, con el union nativo terminás escribiendo esas funciones helper a mano. fp-ts ya te las da armadas y probadas.

¿Puedo usar discriminated unions y fp-ts en el mismo proyecto?
Se puede, pero mezclarlos sin criterio genera inconsistencia — algunas funciones devuelven Resultado<T> y otras Either<E, A>, y quien lee el código tiene que recordar dos convenciones. Si conviven, que sea con una frontera clara: por ejemplo, fp-ts solo en la capa de composición de servicios, unions nativos en el resto.

¿Esto aplica igual en Next.js que en un backend Node puro?
El patrón es el mismo, pero en Next.js con Server Actions y validación de formularios el discriminated union nativo suele ganar por defecto: el consumidor final es un componente React que necesita un if simple para renderizar, no una cadena de transformaciones. Ahí meter fp-ts agrega una capa que el framework no pide.

Mi postura

Instalé fp-ts, lo probé en profundidad para el post anterior, y mi conclusión no es "no lo usen". Es: no lo instalen por default. Empiecen con el discriminated union que ya trae el lenguaje — está en la documentación oficial, cualquiera lo lee, cualquiera lo mantiene. El día que se repita el mismo if de manejo de error más de tres veces en el mismo archivo, ahí sí abran la carpeta de fp-ts y evalúen si chain les ahorra ese código repetido.

La curva de aprendizaje no es gratis para nadie del equipo. Que la pague quien realmente la necesita, no quien solo quería que el código se vea prolijo.

Si esto es un experimento de equipo, documentenlo como tal: qué problema tenían antes, qué cambió, qué costó. Sin esa bitácora, cualquier claim sobre "mejoró la legibilidad" es una opinión disfrazada de dato — la misma trampa en la que caigo si comparo inferencia local de modelos como hice con Qwen3 en Ollama sin dejar claro qué es medición y qué es impresión.


Fuente original:


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)