fp-ts Alternatives in TypeScript: When the Abstraction Is Worth It

typescript dev.to

Last week I wrote about functional programming with TypeScript and what fp-ts teaches you and I landed on a conclusion that left me comfortable but not fully settled: a native union type with a couple of helper functions solves eighty percent of the cases where someone reaches for Either. The comment I got most was some version of "so fp-ts is useless." That's when I realized I'd left the door half-closed, and I want to close it properly here.

My thesis for this post: fp-ts isn't useless, it's just misapplied most of the time. It solves one specific problem — composing errors from multiple independent sources without the code collapsing into nested ifs — and that problem shows up far less often than the number of Either imports I see in random codebases would suggest.

fp-ts alternatives typescript: the question I actually ask

Before I bring fp-ts into a project, I don't think about type safety or elegance. I count. How many independent failure points do I need to combine in a single operation? One or two, a union type covers it and anyone on the team reads the flow in one pass. Five validations that can each fail on their own, where I need every error and not just the first one that blew up — that's where Either and its combinators start paying for the learning cost they demand.

That count is the whole decision for me. It's not a matter of taste or which paradigm you like more. Below three independent failure points, fp-ts is vocabulary without payoff. At three or more, with accumulation as a requirement, a union type forces you to hand-roll the exact machinery fp-ts already built.

What the official source says and doesn't say

The fp-ts GitHub repo presents itself as a library for "typed functional programming in TypeScript," with implementations of structures like Option, Either, TaskEither, and composition utilities like pipe plus the Monad, Applicative, and Functor instances for each of those types.

What the docs don't say — because that's not their job — is when it's worth using in a real project. That's a team decision, not a property of the library. The source gives you the tool and the typed contract; it doesn't tell you whether your problem actually has the shape that needs it. That's exactly where most arguments I've seen online get stuck: people debate syntax when they should be debating whether the problem even qualifies.

Where people get it wrong: the recipe I keep seeing repeated

The common recipe: someone reads about Either, likes the type safety, and installs it as the default for any function that might fail. A parseInt that can return NaN. A database query that might not find a row. A fetch that might throw a 404. All wrapped in Either<Error, T>, with pipe, chain, and fold at every link in the chain.

The hidden cost doesn't show up in the file the person who wrote it opens. It shows up when another team member — someone who doesn't live in the functional paradigm every day — has to read that chain to fix a bug. They have to understand what a Functor is, why chain isn't the same as map, and why the error stays "trapped" until someone unwraps it with fold. That reading cost is real, and type safety doesn't make up for it if the underlying problem was simple to begin with.

The counterexample that does justify the investment looks different: a form with fifteen fields, each with its own validation, where what you need to show the user is the complete list of errors, not just the first one that failed. There, Either combined with Applicative — which lets you accumulate instead of short-circuiting on the first error — solves something a native union type can't solve without hand-reinventing the wheel.

// composed validation pipeline, the case where fp-ts earns its cost
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"

const validarEmail = (email: string): E.Either<string, string> =>
  email.includes("@") ? E.right(email) : E.left("email invalido")

const validarEdad = (edad: number): E.Either<string, number> =>
  edad >= 18 ? E.right(edad) : E.left("edad insuficiente")

// sequenceT or Apply let you accumulate errors from both validations
// instead of short-circuiting as soon as the first one fails
Enter fullscreen mode Exit fullscreen mode

Decision matrix: when yes, when no

This isn't a table of absolute truths. It's the criterion I apply before choosing, and it depends heavily on the team that has to maintain the code afterward.

  • Use it if: you need to combine three or more independent validations and you need the full set of errors, not just the first.
  • Use it if: the team already has prior experience with functional programming and the vocabulary isn't an entry barrier.
  • Avoid it if: the flow has a single failure point that a plain if or a two-to-three-variant union type can handle.
  • Avoid it if: the project has a short lifespan or the team rotates often — the learning curve doesn't pay off in time.
  • Check first: how many people on the team are going to touch that file in the coming months. It's the question that weighs heaviest in my actual decision.

I apply this same "the abstraction pays off when the problem has a composite shape" logic elsewhere. When I wrote about revalidatePath vs revalidateTag in Next.js, the point was similar: the finer-grained tool wins when the use case has real granularity, and loses when brute force already does the job.

The limits of this comparison

I don't have an experiment measuring onboarding time between teams that use fp-ts and teams that don't. There's no performance benchmark between Either and a union type — for the typical case, the runtime difference is irrelevant because both are lightweight structures with no real overhead. What I have is a readability criterion based on the shape of the problem, not a productivity measurement, and I'm not going to dress it up as more than that.

I also can't claim fp-ts is "better" or "worse" in absolute terms: that depends on the team, on people turnover, and on how much prior experience the group has with functional programming. If someone wants an answer that doesn't depend on context, they won't find it here — and I'd be suspicious of any post that offers one without data.

FAQ

Is fp-ts still useful in 2025 or has native TypeScript replaced it?
It's still useful for the specific case of composing multiple errors. Native TypeScript with union types covers most simple cases, but it doesn't replace the accumulation combinators fp-ts already has solved.

What's the simplest alternative to fp-ts's Either?
A union type like { ok: true, value: T } | { ok: false, error: E }, combined with your own helper functions for map and chain if you need them. Covers simple validations without the extra vocabulary.

Does fp-ts perform better than handling errors with try/catch?
There's no public evidence of a relevant performance difference between the two approaches for a typical application case. The decision should be based on readability and problem shape, not speed.

Is it worth learning fp-ts if I've never used functional programming?
Depends on the project. If the team doesn't have that foundation and the problem doesn't demand composing multiple errors, the learning curve probably won't pay off in time.

What replaces fp-ts's Option?
A T | null or T | undefined type with the narrowing functions TypeScript already provides. For the "value may or may not exist" case, native language features are almost always enough.

In which projects would you recommend fp-ts from day one?
In form or input-data validation pipelines with multiple independent rules, where you need to show all the errors found and the team already knows the paradigm.

My final take

I'm not going to recommend fp-ts as the default for a new project, and I think most posts that do are optimizing for showing off the type system instead of solving the actual problem in front of them. I'll recommend it when the problem has the shape the library solves better than anything else: composed validation with error accumulation. Outside of that case, a native union type is more readable for whoever opens the file next, and that person is almost never the one who wrote it.

If you're weighing this on a real project, the exercise is concrete: count how many independent failure points the function you're writing has. One or two, stick with native. Three or more with a need to accumulate, that's when you open the door to fp-ts — and accept upfront that the team is going to take a while to get used to it. That trade-off, I think, is honest. Pretending there's no cost isn't.

Original source:


This article was originally published on juanchi.dev

Source: dev.to

arrow_back Back to Tutorials