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
}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 }
}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
)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:
- Can the team read
ReaderTaskEither<R, E, A>without Googling? - Is there a linter configured to enforce consistent fp style?
- Are domain errors already typed as discriminated unions?
- Are you using
strict: trueintsconfig.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:
- fp-ts — Official GitHub: https://github.com/gcanti/fp-ts
- Sahand Javid — Functional Programming with TypeScript (YouTube playlist): https://www.youtube.com/playlist?list=PLuPevXgCPUIMbCxBEnc1dNwboH6e2ImQo
Related Articles
Qwen3 locally with Ollama: what changed in the architecture and whether it's worth switching
Qwen3 landed with thinking mode and real improvements in code generation. But before you replace the model already running in your Ollama setup, there are technical questions you need to answer first. I answer them here without selling hype.
Aug 02 2026 · 9′ · Tutorials · TypeScript · Inferencia Local
Prisma Query Logging and PostgreSQL: Where the ORM Ends and the Database Begins
Prisma's query log shows you what SQL the ORM generated, not why that SQL is slow. A practical guide to knowing when logging is enough and when you need to go straight into PostgreSQL.
Jul 29 2026 · 8′ · Tutorials · TypeScript · postgresql
npm Dependencies: How to Evaluate a Library Before It Hits Production
A clean package.json says nothing about who's going to fix that library when it breaks. Practical criteria for evaluating maintenance, surface area, types and transitive deps before adding a dependency to a TypeScript stack.
Jul 28 2026 · 8′ · Tutorials · TypeScript · pnpm
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.