Functional Programming in TypeScript: I Applied It to My Real Codebase — Here's What Survived (and What Didn't)
80% of the code written applying FP in TypeScript never makes it to production. Yeah, you read that right. Not because the concepts are bad — but because the gap between a pipe() example and a real Server Action with Prisma, side effects, and network errors is so wide that most people discover it too late. I discovered it at 2am with a broken deploy.
I was going through Sahand Javid's playlist on FP with TypeScript and fp-ts — which the signal pool flagged as a GEM with a score of 91 — and I got the itch. "I can apply this to juanchi.dev right now." Four hours later I had cleaner code in two modules and a mess in three. Here's what I learned.
Functional Programming TypeScript in Production: What It Actually Means in a Next.js 16 Stack
My thesis going in: FP in TypeScript is powerful but carries a readability cost that isn't always worth it. The secret isn't applying it everywhere — it's knowing exactly which layers of the stack benefit from functional patterns and which ones just shuffle the complexity around.
The concrete stack where I ran the experiment: Next.js 16 App Router, strict TypeScript, Prisma ORM, Server Actions, Railway for infra. Not a toy project. A codebase with real users that's already handed me a couple of memorable headaches (the Railway migration was one of the most instructive).
What fp-ts Is and Why It Matters
fp-ts gives you first-class algebraic types in TypeScript: Option<A>, Either<E, A>, TaskEither<E, A>, and the pipe() operator for composing functions without mutation. The idea is to eliminate implicit side effects and make errors explicit values in the type instead of exceptions.
Sounds fantastic. And in some cases it genuinely is.
What Survived: pipe() and Option for Null Handling in Prisma
The first pattern I adopted — and that's still alive today — is Option<A> for handling Prisma queries that can return null.
Before fp-ts, I had this scattered across several Server Actions:
// ❌ Before: null checks all over the place, easy to forget one
async function getUserProfile(userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId } });
// What if someone adds a step here and forgets the null check?
if (!user) {
return null;
}
const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
if (!profile) {
return null;
}
return { user, profile };
}
The problem isn't that the code is ugly. The problem is that every if (!x) return null is a point where someone (me, on a Friday afternoon) can add intermediate logic and silently break the contract. I watched it happen. It cost me a bug that took 40 minutes to track down.
With Option and pipe():
import { pipe } from 'fp-ts/function';
import * as O from 'fp-ts/Option';
import * as TE from 'fp-ts/TaskEither';
// ✅ After: absence is an explicit value in the type
const getUserProfile = (userId: string): TE.TaskEither<Error, { user: User; profile: Profile }> =>
pipe(
// Look up the user; if not found, it's a Left with a descriptive error
TE.tryCatch(
() => prisma.user.findUnique({ where: { id: userId } }),
(e) => new Error(`Error fetching user: ${String(e)}`)
),
TE.flatMap((user) =>
user
? TE.right(user)
: TE.left(new Error(`User ${userId} not found`))
),
// Chain without losing the user context
TE.flatMap((user) =>
pipe(
TE.tryCatch(
() => prisma.profile.findUnique({ where: { userId: user.id } }),
(e) => new Error(`Error fetching profile: ${String(e)}`)
),
TE.flatMap((profile) =>
profile
? TE.right({ user, profile })
: TE.left(new Error(`Profile for ${user.id} not found`))
)
)
)
);
Is it more verbose? Yes, quite a bit. Is it worth it? In this specific layer, yes. The return type TaskEither<Error, {...}> tells the compiler — and any dev on the team — that this function can fail and that the error is a value that has to be handled. You can't just ignore it.
What fully convinced me: when I wired this pattern up with strict TypeScript and propagating types, the compiler started screaming at the function's consumers. Before, null values would silently filter through all the way to the render.
Verdict: survived. I use it on every Prisma query that has more than one dependent step.
What Didn't Survive: Either for Error Handling in Server Actions with Side Effects
Here's the uncomfortable part. And I'm telling it because nobody documents this.
I tried replacing the try/catch blocks in my Server Actions with Either<Error, T>. The promise was beautiful: errors as values, clean composition, types that protect you. It lasted two weeks.
The concrete problem: Next.js 16 Server Actions don't live in a pure functional world. They have side effects everywhere — logs, cache revalidations, analytics events, external state mutations. And when you try to stuff Either into that context, the code turns into this:
// ❌ This seemed like a good idea for 11 days
import * as E from 'fp-ts/Either';
async function createPost(data: NewPost): Promise<E.Either<string, Post>> {
// Validation
const validation = validatePost(data);
if (E.isLeft(validation)) {
// Log the error — first side effect that breaks purity
await logger.error('Validation failed', E.getLeft(validation));
return validation;
}
// Save to DB
const result = await E.tryCatch(
() => prisma.post.create({ data: E.getRight(validation) as NewPost }),
String
);
if (E.isLeft(result)) {
// Second side effect: revalidate even though it failed
revalidatePath('/blog');
return result;
}
// Third side effect: notification
await notifySubscribers(E.getRight(result) as Post);
// Fourth side effect: revalidate cache
revalidatePath('/blog');
// And here's where it hit me: this is a try/catch with more ceremony
return result;
}
After two weeks I had an Either wrapping four side effects, and every consumer had to do E.isLeft() + E.getRight() to access the value. The type safety was real, but the cognitive cost for the team was greater than the benefit.
I reverted it. Not with shame — with clarity.
// ✅ The version that survived: honest try/catch + explicit return type
type ActionResult<T> =
| { ok: true; data: T }
| { ok: false; error: string; code?: string };
async function createPost(data: NewPost): Promise<ActionResult<Post>> {
try {
const post = await prisma.post.create({ data });
await notifySubscribers(post);
revalidatePath('/blog');
return { ok: true, data: post };
} catch (error) {
logger.error('Error creating post', error);
return { ok: false, error: 'Could not create the post', code: 'DB_ERROR' };
}
}
It's shorter. It's more readable. And the ActionResult<T> type is still discriminated — TypeScript forces you to check ok before accessing data. I get 80% of the benefit at 20% of the cost.
Verdict: didn't survive. Either in Server Actions with side effects is more ceremony than protection.
The Gotchas Nobody Warns You About Before You Dive Into fp-ts
1. TypeScript Type Inference with fp-ts Types Gets Weird Under Pressure
With TypeScript 7 beta, fp-ts types sometimes produce inference that the compiler resolves to an unexpected intermediate type. I had cases where the inferred type was TaskEither<unknown, unknown> because one function in the pipe didn't have an explicit annotation. Result: the compiler doesn't warn you about the error until you try to consume the result.
The fix: explicitly annotate return types at each step of the pipe when using fp-ts. Don't trust inference for long chains.
// ❌ Inference that betrays you in long chains
const result = pipe(
fetchUser(id), // TaskEither<Error, User>
TE.flatMap(transform), // ← if 'transform' isn't annotated, can infer wrong
TE.map(format)
);
// ✅ With explicit annotations where there's ambiguity
const result: TE.TaskEither<Error, FormattedUser> = pipe(
fetchUser(id),
TE.flatMap((u): TE.TaskEither<Error, TransformedUser> => transform(u)),
TE.map(format)
);
2. pipe() with More Than 6 Steps Is Unreadable in Code Review
I learned this one the hard way. I had a pipe with 8 steps to process a webhook payload. In code review, my teammate took 20 minutes to understand what it was doing. The exact same code with descriptively named functions and three awaits was immediately clear.
Rule I adopted: if the pipe exceeds 5 steps, name the intermediate transformations as separate functions.
3. fp-ts in the Client Bundle: Watch Out with Next.js App Router
If you import fp-ts in a component that ends up in the client bundle, the added weight is non-trivial. In my case, full fp-ts is ~70KB unminified. I discovered it analyzing the bundle with @next/bundle-analyzer. The fix was simple: fp-ts only in Server Actions and server-side utilities. Never in client components. Related to some of the security patterns I apply when reviewing dependencies in production.
4. Team Onboarding: The Real Cost That Tutorials Ignore
If you're on a team with more than one person, every new fp-ts pattern is onboarding time. TaskEither, flatMap, fold — these are concepts that require theoretical context to not look like magic. At Lakaut Hub, I had to write a two-page internal guide just to explain why pipe(TE.tryCatch(...), TE.map(...)) was equivalent to what we used to do with try/catch. The benefit has to be clear enough to justify that cost.
FAQ: Functional Programming in TypeScript in Production
Do I need fp-ts to do functional programming in TypeScript?
No. You can apply FP principles — pure functions, immutability, composition — without installing anything. fp-ts gives you well-implemented algebraic types, but if you're not on a team with theoretical context, start with pure functions and pipe() from lodash/fp or even a 5-line custom implementation. 80% of FP's value comes from the principles, not the library.
When does it make sense to use Option<A> instead of T | null?
When the absence of a value needs to propagate through multiple transformations without each step having to check explicitly. In Prisma queries with dependency chains, Option or TaskEither eliminates intermediate null checks. In a simple form that might return null, T | null with an if is sufficient and more readable.
Does fp-ts play well with Prisma and its generated types?
With friction. Prisma types are interfaces that assume mutability and aren't "functor-friendly" by nature. The integration works, but you need explicit wrappers. TE.tryCatch(() => prisma.xxx.findUnique(...), toError) is the standard pattern. Nothing magical about it.
Does FP in TypeScript affect production performance?
In my stack, not in any measurable way. The difference shows up in bundle size if you import fp-ts on the client (avoid it) and in compilation time with complex type chains (real but minor). The runtime overhead of pipe() and algebraic types is negligible compared to a query to Postgres.
What FP pattern would you recommend for someone starting out?
First: pipe() for composing functions without unnecessary intermediate variables. Second: pure functions for data transformations. Third, only once you're comfortable: Option/Either for handling absence and errors as values. In that order. Not in reverse.
Is it worth learning fp-ts if I already know TypeScript well? Depends on the type of code you write. If you work with complex data transformations, processing pipelines, or domains where errors are business values (not exceptions), yes. If your main codebase is CRUD with Next.js and Server Actions, the ROI is low. I use fp-ts in ~30% of the codebase — exactly where algebraic types give a real advantage.
The Uncomfortable Thing Nobody Says About FP in Production TypeScript
My final position: FP in TypeScript is a precision tool, not a codebase philosophy. Sahand Javid's playlist that kicked off this experiment is excellent — the concepts are well explained, the examples are clear. The problem is that clear examples live in a world without side effects, without Next.js revalidation, without production logs, and without teammates who see a fold() for the first time at 4pm on a Friday.
What survived in my stack: Option for null handling in Prisma chains, TaskEither for async operations with well-defined business errors, pipe() for composing pure data transformations. What didn't survive: Either in Server Actions with side effects, pipes with more than 5 steps and no intermediate names, fp-ts in the client bundle.
The pattern I apply today: start with strict TypeScript and my own discriminated unions ({ ok: true; data: T } | { ok: false; error: string }). When a transformation chain starts accumulating null checks or error handling gets verbose, that's when I bring in fp-ts specifically. Not before.
It's the same logic I use when evaluating any new abstraction — whether it's a new security pattern for dependencies or an agent architecture redesign: does it replace real complexity, or does it just move it somewhere else?
With FP in TypeScript, the honest answer is: it depends exactly on where you apply it.
If you're trying to fit fp-ts into a real codebase and you hit a gotcha I didn't cover here, send me the snippet. I'm genuinely interested.
Original source:
- Functional Programming with TypeScript and fp-ts curated playlist — Sahand Javid: https://www.youtube.com/playlist?list=PLuPevXgCPUIMbmgUSky9Y9MAQFH0KLUF0
Related Articles
Themis vs Web Crypto API: I tested both for encryption in my digital identity app and the tradeoffs aren't obvious
I'm building Lakaut ID, a biometric identity validation system. Cryptography here isn't an academic exercise — it's the heart of the product. I compared Themis against Web Crypto API on concrete cases, with real code, and the tradeoffs genuinely surprised me.
Spring Boot in Real Production: What My Lakaut Codebase Taught Me That the Official Docs Leave Out
Three years running Spring Boot in real production at Lakaut AC left me with logs, incidents, and metrics no tutorial will ever show you. The official docs assume an environment that doesn't exist on Railway with real JVM tuning.
Clipboard API Fails in TypeScript: The 4 Cases Nobody Documents and How I Found Them in My Own Code
navigator.clipboard.writeText looks trivial until your app silently breaks in production with zero visible error. I found 4 cases the docs never mention: insecure context, lost focus, revoked permissions on iOS, and React timing. Here are the real patterns with copyable code.
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.