A native discriminated union already does what Either promises
This week I wrote about functional programming with TypeScript and what fp-ts teaches you. I was happy with what I explained, but while I was putting together the Either and Option examples, a question crept in that wouldn't leave me alone: does all that new vocabulary - pipe , chain , fold , TaskEither - solve something that TypeScript doesn't already solve on its own? My thesis, no beating around the bush: fp-ts is a powerful tool but it's overengineering for most codebases belonging to teams that don't come from Haskell or Scala. A well-typed discriminated union, the kind the language already ships with, is enough for the problem that almost everyone installing fp-ts is trying to solve: handling errors without exceptions and without loose nulls floating around. This isn't a retraction of the previous post. It's the missing half: when paying the curve is worth it, and when it's money spent on abstraction for nothing. fp-ts either option alternative typescript: the real problem The pain that brings people to Either isn't "I want functional programming." It's smaller and more concrete: a function can fail, and I want the compiler to force me to handle that failure before touching the result. No try/catch that gets forgotten, no null that leaks three layers up. That problem has a native solution in TypeScript that doesn't need any library: the discriminated union. It's documented in the TypeScript Handbook, narrowing section, and right there the language explains exactly this - how a common literal field lets the compiler narrow the type inside an if or a switch without any extra abstraction. What the Handbook doesn't say, because it's not its job, is when that pattern stops being enough. That part you have to figure out with judgment, not documentation. Native union vs Either: same problem, two different costs With fp-ts, a function that can fail looks like this: import { Either, left, right } from 'fp-ts/Either'; function dividir(a: number, b: number): Either { if (b === 0) return left('division por cero'); return right(a / b); } To consume that result you need pipe , fold or match , and to understand that Either is a functor with two cases. None of this is hard once you've internalized it. The cost isn't the specific difficulty: it's that every new dev on the team has to internalize it before they can read the code fluently. The native alternative, with a discriminated union: type Resultado = | { ok: true; valor: T } | { ok: false; error: string }; function dividir(a: number, b: number): Resultado { 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 knows "valor" exists here } else { console.log(r.error); // and here it knows "error" exists } The compiler narrows the type with just the if (r.ok) . Nothing to import, nobody to explain what a functor is to, and anyone who's ever seen a switch in their life understands the flow in ten seconds. This is the same mechanism I already used to model the result of signing a document in CAdES vs XAdES in Java: two valid shapes, one discriminant field, zero ambiguity. Where people get it wrong with fp-ts The typical recipe I see - and that I myself followed before stopping to think it through - is: "saw an fp-ts video, looks clean, drop it into the project." The hidden cost shows up three sprints later, when someone on the team who's never touched functional programming has to debug a six-step pipe with nested chain s and doesn't even have the vocabulary to google the error. The counterexample that does justify the curve: composing several operations that can fail in a chain, where each step depends on the previous one and you need the error to propagate automatically without writing an if (!r.ok) return r after every line. There chain isn't decoration, it's what saves you from repeated code. If you have five chained validations and each one returns a discriminated union, you end up writing the same check five times. With Either and pipe , you write it once and it applies to the whole chain. That same pattern of "the abstraction is justified when the volume of repetition demands it, not before" is what I discussed with Virtual Threads in Java: lightweight concurrency doesn't save you from a badly placed synchronized - the new tool solves one specific problem, not every adjacent problem. Decision matrix: when to pay the curve and when not to | Situation | Native discriminated union | fp-ts (Either/Option) | |---|---|---| | One function, one possible failure, consumed once | More than enough | Overengineering | | Chaining 4+ operations that can fail in sequence | Gets repetitive | chain /pipe wins here | | Team with no FP experience, high turnover | Reads without prior explanation | Every onboarding costs time | You need to compose with Promise and typed error at the same time | Has to be built by hand | TaskEither already solves it | | Code will occasionally be touched by people from other teams | Lower entry barrier | Real entry barrier | | You already have a consistent functional codebase | Breaks consistency | Fits naturally | This table isn't a closed conclusion - it's a starting point for deciding, case by case, whether the problem in front of you is "a function that fails" or "a pipeline of composed failures." The difference between those two things is the difference between needing fp-ts and not needing it. A short criterion I use so I don't have to think it through from scratch every time: if I can write the full error handling in fewer than five lines with an if , I don't open the fp-ts folder. If that if repeats more than three times in the same file, that's when I start looking at pipe . flowchart LR A[Function that can fail] --> 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] The limits of this comparison I don't have onboarding-time benchmarks or bug-avoidance metrics for either approach - and even if I saw them published, I wouldn't trust them without knowing the methodology. What's here is a design criterion, backed by how TypeScript officially documents narrowing with discriminated unions, not a controlled experiment. It's not a veto on fp-ts either. It's a real tool, with a serious community behind it, and in projects that already adopted it end to end, changing course halfway through would be worse than the original learning curve. The decision to adopt it gets made at the start of the project, not in the file you're touching today. If the team already comes from Scala or Haskell, the math changes completely: for those people fp-ts isn't a curve, it's the language they already speak. This analysis is aimed at the most common case in TypeScript: teams who learned the language coming from JavaScript, not from a pure functional language. FAQ Does fp-ts's Either do something a discriminated union can't? For the simple case, no. For composing long chains of fallible operations with automatic error propagation, chain and pipe avoid repeating the manual check at every step. There you do get a functional difference, not just a cosmetic one. Is it worth learning fp-ts if I've never used a functional language? If the project already uses it, yes, because the alternative is reading code you don't understand. If you're evaluating whether to drop it into a brand-new project with a team that has no functional background at all, that's a different call - weigh whether the real problem is a chain of fallible steps or just one isolated function, because that's usually where a native union already covers most cases without the extra vocabulary. Does fp-ts's Option replace T | null ? Conceptually, yes - Option is Some | None , quite similar to T | null but with composition methods. The practical difference is that with T | undefined , TypeScript already forces you to check with strict mode on, without installing anything. Does the native discriminated union have any real disadvantage compared to Either? Yes: it has no combinators. If you need to map, chain, or combine several fallible results generically, with the native union you end up writing those helper functions by hand. fp-ts already gives them to you built and tested. Can I use discriminated unions and fp-ts in the same project? You can, but mixing them without a clear rule creates inconsistency - some functions return Resultado and others Either , and whoever reads the code has to remember two conventions. If they coexist, do it with a clear boundary: for example, fp-ts only in the service composition layer, native unions everywhere else. Does this apply the same way in Next.js as in a plain Node backend? The pattern is the same, but in Next.js with Server Actions and form validation, the native discriminated union usually wins by default: the final consumer is a React component that needs a simple if to render, not a chain of transformations. There, dropping in fp-ts adds a layer the framework isn't asking for. My take I installed fp-ts, tested it in depth for the previous post, and my conclusion isn't "don't use it." It's: don't install it by default. Start with the discriminated union the language already ships with - it's in the official docs, anyone can read it, anyone can maintain it. The day the same error-handling if repeats more than three times in the same file, that's when you open the fp-ts folder and evaluate whether chain saves you that repeated code. The learning curve isn't free for anyone on the team. Let it be paid by whoever actually needs it, not by whoever just wanted the code to look clean. If this is a team experiment, document it as one: what problem you had before, what changed, what it cost. Without that log, any claim about "it improved readability" is an opin
Comments
No comments yet. Start the discussion.