DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Functional programming with TypeScript: what fp-ts teaches you even if you never ship it

Functional programming with TypeScript: what fp-ts teaches you even if you never ship it

The correct solution for handling nullables in TypeScript is to add more types. I know that sounds like bureaucracy. But it was exactly the idea behind fp-ts's Option<T> that made me realize every undefined I was returning without context was a broken contract waiting to blow up at runtime — I've seen that exact blow-up in a Next.js action, where an empty undefined came back from a query and three components down the tree just assumed it would always be there.

I never installed fp-ts in production. Didn't need to. But reading its source changed how I think about data flows in strict TypeScript — in Server Actions, in Zod schemas, in any function that deserves an honest signature.

My thesis, and I'll stand behind it: fp-ts is a university, not a framework most teams should deploy. Treat it as a way to sharpen how you think about contracts, not as a dependency you add on day one. Ignoring it completely, though, means leaving some of the most useful reasoning patterns for strict TypeScript day-to-day work sitting right there on the table.


Why fp-ts makes real-world TypeScript developers uncomfortable

The problem isn't that fp-ts is hard. The problem is that it imposes a complete vocabulary — Functor, Monad, TaskEither, IO — before you can do something as basic as parse a date without exploding.

If you come from a pragmatic stack — Next.js, Prisma, Zod, Railway — the initial learning curve feels expensive with no clear return. And in many cases that skepticism is fair. The philosophical overhead is real.

But there are three concepts inside fp-ts that survive outside the pure functional ecosystem: Option, Either, and pipe. They're ideas, not just libraries. And that distinction matters.


Option, Either, and pipe: what you take away even if you install nothing

Option: make explicit what might not be there

Option<A> is basically Some(value) | None. The idea: a function that might not return a value says so in its signature, not in the docs.

In vanilla TypeScript, this translates to a pattern you're probably already using but haven't formalized:

// Without Option: the contract is hidden in the return type
function findUser(id: string): User | undefined {
  return db.find(u => u.id === id)
}

// With the Option idea internalized: the name and structure
// communicate that the result might not exist
type Option<A> = { _tag: 'Some'; value: A } | { _tag: 'None' }

function findUser(id: string): Option<User> {
  const user = db.find(u => u.id === id)
  return user ? { _tag: 'Some', value: user } : { _tag: 'None' }
}

// The consumer can't ignore the None without an explicit match
function processUser(opt: Option<User>): string {
  if (opt._tag === 'None') return 'User not found'
  return opt.value.name
}
Enter fullscreen mode Exit fullscreen mode

Do you need to import fp-ts for this? No. Does the concept force you to think differently? Yes.

In Next.js Server Actions, where the result of a database operation can legitimately be empty, this pattern prevents a silent undefined from reaching the client without anyone handling it. The same applies when you combine this with Zod for runtime validation: the schema fails explicitly instead of returning a hidden nullable.

Either: errors as values, not as exceptions

Either<E, A> is Left(error) | Right(value). The convention is that Left carries the error and Right carries the happy path.

The insight you take away: when a function can fail in different ways, modeling that in the return type is more honest than throwing an exception and hoping someone catches it.

// Error modeling with Either without installing fp-ts
type Either<E, A> =
  | { _tag: 'Left'; error: E }
  | { _tag: 'Right'; value: A }

type ParseError = { type: 'invalid_format'; message: string }
type DBError = { type: 'not_found'; id: string }
type DomainError = ParseError | DBError

// The caller knows exactly what can go wrong
async function getProfile(
  rawId: unknown
): Promise<Either<DomainError, Profile>> {
  // Validation: can fail with ParseError
  if (typeof rawId !== 'string' || rawId.length === 0) {
    return {
      _tag: 'Left',
      error: { type: 'invalid_format', message: 'ID must be a non-empty string' }
    }
  }

  // Query: can fail with DBError
  const profile = await db.profiles.findUnique({ where: { id: rawId } })
  if (!profile) {
    return { _tag: 'Left', error: { type: 'not_found', id: rawId } }
  }

  return { _tag: 'Right', value: profile }
}
Enter fullscreen mode Exit fullscreen mode

This isn't academic code. It's a pattern that emerges naturally once you're on TypeScript strict and you get tired of nested try/catch blocks where the error type is unknown. If you're also managing caching in Next.js App Router, having errors as values makes revalidation decisions much more predictable.

pipe: composition without nesting

pipe from fp-ts is a left-to-right composition function. The value enters from the left, transformations are applied in order, the result comes out the right.

The idea in TypeScript without external dependencies:

// Without pipe: nesting that reads from inside out
const result = formatDate(filterActive(sortByName(users)))

// With a minimal pipe implementation of your own:
function pipe<A>(value: A): A
function pipe<A, B>(value: A, fn1: (a: A) => B): B
function pipe<A, B, C>(value: A, fn1: (a: A) => B, fn2: (b: B) => C): C
function pipe(value: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
  return fns.reduce((acc, fn) => fn(acc), value)
}

// Now it reads left to right, the way you actually think about the flow
const result = pipe(
  users,
  sortByName,    // first you sort
  filterActive,  // then you filter
  formatDate     // then you format
)
Enter fullscreen mode Exit fullscreen mode

The overhead of implementing pipe yourself is minimal. The readability gain when you're chaining transformations is immediate.


Where fp-ts charges an overhead you don't want to pay

So far I've described the concepts that survive decoupled from the library. But it would be dishonest not to name what makes fp-ts unviable as a production framework for most teams:

The full ecosystem demands total commitment. TaskEither, ReaderTaskEither, IOEither are powerful abstractions, but the resulting code is hard to read for anyone who doesn't live in that paradigm. On a three-person team with mixed TypeScript levels, adding fp-ts as a production dependency introduces a real cognitive barrier — the kind that shows up as "wait, what does this signature even return" in a PR review, not as a compile error.

The typing is verbose in ways native TypeScript already handles better in 2025. With satisfies, as const, discriminated unions, and the infer operator, modern TypeScript covers a lot of the territory fp-ts was filling when types were less expressive.

There's no escaping the all-or-nothing law. If you mix pipe and Option from fp-ts with imperative code, the result is worse than picking one or the other. Consistency is expensive.

This isn't a criticism of fp-ts — the official repo has remarkable engineering and Giulio Canti built something serious. It's an observation about fit: most projects don't have the team context or the homogeneous codebase to absorb the cost.


Checklist: when to internalize the concepts vs. install the library

Before deciding what to do with fp-ts on a real project, run through this matrix:

Criterion Internalize concepts Install fp-ts
Team of 1-2 people with strict TypeScript ⚠️ Evaluate
Mixed team, varying TS levels
New codebase, greenfield ⚠️ Only if team already knows FP
Project with many complex async/error flows ✅ If the team is aligned
Library you're going to publish (not an app) ❌ Don't add the dep to others
You want to learn FP in TypeScript ✅ For study, not immediate prod

Things to check before installing anything:

  1. Can the team read ReaderTaskEither<R, E, A> without Googling?
  2. Is there a linter configured to enforce consistent fp style?
  3. Are domain errors already typed as discriminated unions?
  4. Are you using strict: true in tsconfig.json? (If not, start there; the post on strict mode in TypeScript covers the 6 options that matter most.)

If you answered no to the first three, fp-ts concepts serve you better as a design guide than as an active dependency.


What you CANNOT conclude from this analysis

These are the honest limits of what this post can actually claim:

  • No performance benchmarks between fp-ts and native TypeScript. If that's critical for your decision, you need to measure it in your own context.
  • No adoption data from real teams on how long the average team takes to absorb fp-ts. Anecdotes on Twitter go in both directions.
  • Sahand Javid's playlist (Functional Programming with TypeScript) is a solid entry point and evidence of community interest, but it's not official documentation of production success stories.
  • What works in a Next.js Server Action might not be the right pattern for a background jobs service with high concurrency. Context changes the equations.

Common mistakes when approaching fp-ts

Reading the theoretical documentation before the code. The fp-ts repo is dense with category theory. If you start there without seeing concrete code first, you'll probably quit. Better to start directly with Option and Either examples.

Converting all your existing imperative code. The worst possible outcome is a codebase that's half functional, half imperative with no consistency. If you're going to adopt the style, you need a clear boundary: new module, new feature — not a mixed refactor.

Confusing pipe with total composition. pipe improves readability for linear transformations. It doesn't solve the complexity of branching flows or side effects. For that you need Either or TaskEither, which carry their own cognitive cost.

Treating fp-ts as the only road to functional code in TypeScript. It's not, and I think that's the part evangelists skip. If what you want is to avoid mutations, prefer pure functions, and express errors in types, vanilla TypeScript with discriminated unions and a consistent style gets you most of the way there — I'd estimate somewhere around 80%, though I haven't measured it precisely, just observed it across the patterns above. Whether the remaining gap is worth fp-ts's overhead is a team call, not an absolute technical truth.


FAQ: fp-ts and functional TypeScript

Do I need to know Haskell to understand fp-ts?
No. It helps to have an intuition for parametric types and higher-order functions, but you don't need category theory vocabulary to use Option and Either productively. The Sahand Javid playlist is designed for TypeScript developers without a formal functional background.

Is fp-ts dead? I heard development slowed down.
The fp-ts GitHub repo is still active, though the core is stable. Giulio Canti is working on effect, which is the evolution of the ecosystem with a more pragmatic approach and better integration with modern TypeScript. If you're evaluating the ecosystem in 2025, effect deserves a separate look.

Can you use just pipe from fp-ts without pulling in the whole ecosystem?
Technically yes, but fp-ts's tree-shaking isn't perfect. In practice, many teams implement their own 10-line pipe to avoid the dependency. There's nothing magical in fp-ts's implementation that you can't reproduce yourself.

How does this integrate with Zod?
Zod schemas already express the parsing result as SafeParseReturnType<T>, which is structurally similar to Either. If you're already using safeParse instead of parse, you're applying the same principle: errors as values, not as exceptions. The integration with Zod for runtime validation is natural if you think of schemas as pure functions that return an implicit Either.

What about debugging? Is the stack trace with fp-ts usable?
This is one of the real costs that few guides mention. When something fails inside a pipe chain with several map and chain calls, the stack trace can be confusing because the functions are anonymous or highly generic. In development you solve it with explicit logging between steps; in production it's an operational cost you need to account for.

Does any of this apply if I'm working with Server Actions in Next.js?
Either specifically is very useful in Server Actions because those functions can fail in different ways — validation, DB, permissions — and you need to communicate that to the client without throwing exceptions that Next.js captures in ways you don't always control. Modeling the return as a discriminated object is more predictable than relying on React's error boundary.


What I'd do differently (and the position I'm keeping)

If I could do the journey again: I'd read the fp-ts source code before any tutorial. The source for Option and Either is short, well-typed, and more instructive than ten blog posts including this one.

What I take away from fp-ts isn't the library. It's the habit of thinking about functions as contracts where the signature says everything: what comes in, what can come out, what can fail. That applies to any strict TypeScript, with or without fp-ts installed. The same applies when you're designing portable tools for MCP or any system where the data contract is the first line of defense.

What I don't buy is the evangelism that fp-ts is the only serious path to mature TypeScript. It's a tool with a very specific fit. Outside that fit, the cognitive and onboarding overhead outweighs the benefits for most teams in most contexts.

My practical recommendation, and the one I actually apply: spend an afternoon with the official repo, implement Option and Either yourself from scratch without installing anything, and decide from there whether the full ecosystem is worth the cost in your context. If the manual implementation already solves your problem, you already have your answer — and the uncomfortable question worth sitting with is whether you're reaching for fp-ts because your problem needs it, or because it feels like the "serious" thing to do.


Original sources:


This article was originally published on juanchi.dev

Top comments (0)