Functional Programming in TypeScript
Functional programming is not academic mysticism. It makes code more predictable, testable, and less error-prone. This guide covers practical FP in TypeScript: pure functions, immutability, composition, map/filter/reduce, Option/Either for null values and errors, and why pragmatic FP matters more than monad dogma.
Key Points
- Topic Focus: AI、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Functional Programming in TypeScript
When functional programming (FP) comes up, many frontend developers immediately think of monads, functors, and category theory: a collection of terms that sound intensely academic. They then quietly conclude, "That is for people who write Haskell. I build business applications with Vue/React, so I have no use for it."
That is a misconception. Most of the time, the core ideas of FP are not academic mysticism. They are a set of engineering practices that make your code more predictable, easier to test, and less prone to bugs. The map, filter, and reduce methods you already use every day are FP's primary tools.
This article does not cover category theory or pursue monad dogma. It focuses only on FP practices that you can apply immediately in TypeScript to make a tangible improvement in code quality.
1. Why We Should Take FP Seriously
First, let us clarify the three practical engineering benefits of FP. They form the foundation for everything that follows:
- Predictability: Given the same input, a pure function always returns the same output and never secretly modifies external state. When reading code, you do not need to maintain a mental model of the "current state of the world."
- Testability: Testing a function without side effects is simply
input → expected output; there is no need to mock a pile of dependencies. - Composability: Small functions can be assembled into larger pieces of logic like building blocks. Expressions such as
map(filter(fn))work because these functions all follow the same "contract."
Notice that none of these benefits is about "showing off." FP is not supposed to produce code that nobody else can understand. Quite the opposite: it helps you write code that you can still understand three months later.
OOP is not the enemy, as we will discuss later. First, let us sharpen our FP toolkit.
2. Pure Functions: The Foundation of Predictability
A pure function has two requirements:
- Given the same input, it always returns the same output.
- It has no side effects: it does not modify external variables, write files, send requests, or mutate its arguments.
Compare these examples:
// Impure: depends on an external variable and secretly modifies it
let discount = 0.8;
function price(cart: number[]) {
return cart.reduce((sum, x) => sum + x * discount, 0);
}
discount = 0.9; // Change the global state, and every call produces a different result
// Pure: every dependency is passed as an argument, and no external state is modified
function totalPrice(cart: number[], discount: number): number {
return cart.reduce((sum, x) => sum + x * discount, 0);
}
The benefits of pure functions are concrete:
Easy to test. No setup, teardown, or mocks are required:
expect(totalPrice([100, 200], 0.8)).toBe(240);
Easy to reason about. When you read totalPrice(cart, 0.8), you know the result depends only on those two values. You do not need to search the entire codebase to find out who else changed discount.
Memoizable. Because the same input always produces the same output, you can use the input as a key and cache the result. For computation-intensive pure functions, this is effectively free performance optimization:
function memoize<Args extends unknown[], R>(fn: (...args: Args) => R) {
const cache = new Map<string, R>();
return (...args: Args): R => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key)!;
};
}
const fib = memoize((n: number): number =>
n < 2 ? n : fib(n - 1) + fib(n - 2)
);
Practical advice: push side effects to the boundaries. Business applications inevitably involve I/O, such as reading from a database, sending requests, and writing logs, so they cannot be entirely pure. But you can write the core computation as pure functions and perform I/O only at the outermost layer. This is the well-known Functional Core, Imperative Shell pattern.
3. Immutability: Stop Modifying Objects in Place
One of the most common sources of bugs in JS is multiple references sharing the same object, followed by one part of the program secretly modifying it:
const user = { name: "Alice", age: 28 };
const admin = user;
admin.age = 30;
console.log(user.age); // 30 — you thought user was unchanged, but it was modified
The core idea of immutability is: do not modify a value in place; return a new copy instead. That way, anyone holding the old reference does not need to worry about someone else changing it behind their back.
There are several ways to implement immutability in TS/JS:
1. The spread operator (shallow copy)
const updated = { ...user, age: 30 };
console.log(user.age); // 28, the original object is unchanged
console.log(updated.age); // 30
2. structuredClone (deep copy, supported natively by browsers and Node 17+)
const nested = { list: [1, 2, { x: 1 }] };
const copy = structuredClone(nested);
copy.list[2].x = 999;
console.log(nested.list[2].x); // 1, even the nested value is unchanged
3. Immer (recommended for complex nested state)
Immer lets you write immutable code using syntax that "looks mutable," while generating the new object for you underneath. Redux Toolkit and Zustand both include it:
import { produce } from "immer";
const nextState = produce(state, (draft) => {
draft.users[0].age = 30;
draft.logs.push("updated");
});
// state is unchanged; nextState is a new object, and only the modified parts are new (structural sharing)
The biggest benefit of immutability is traceable state changes. Why does React require state to be immutable? Because the old and new states have different references, so a single oldState !== newState comparison can determine whether anything changed and whether a re-render is needed. If you mutate an object in place, its reference remains unchanged, and the framework has no way to know what happened.
4. Function Composition and Higher-Order Functions: map/filter/reduce Are Everyday Workhorses
A higher-order function is a function that takes another function as an argument or returns a function. map, filter, and reduce are the big three; nearly every array-processing task can be solved elegantly with them.
Consider a realistic example: from a collection of orders, find the completed orders with amounts greater than 100, then calculate their total value.
Imperative approach:
let total = 0;
for (const order of orders) {
if (order.status === "completed" && order.amount > 100) {
total += order.amount;
}
}
Functional approach:
const total = orders
.filter((o) => o.status === "completed")
.filter((o) => o.amount > 100)
.map((o) => o.amount)
.reduce((sum, a) => sum + a, 0);
The functional version is slightly longer, but every step is an independent, readable, and testable unit of meaning. "Filter completed orders," "filter amounts greater than 100," "extract the amounts," and "sum them" read like a description of the business logic rather than the mechanics of a loop.
compose / pipe: assemble small functions into a larger one
When your data flow is f → g → h, you can combine it into a new function:
type Fn = (x: any) => any;
// compose: executes from right to left (f(g(h(x))))
function compose(...fns: Fn[]) {
return (x: any) => fns.reduceRight((acc, fn) => fn(acc), x);
}
// pipe: executes from left to right, matching the natural reading order
function pipe(...fns: Fn[]) {
return (x: any) => fns.reduce((acc, fn) => fn(acc), x);
}
const normalize = pipe(
(s: string) => s.trim(),
(s: string) => s.toLowerCase(),
(s: string) => s.replace(/\s+/g, "-")
);
normalize(" Hello World "); // "hello-world"
In practice, chaining .filter().map().reduce() already covers 90% of use cases. You only need pipe/compose when you want to pass the composed function around as a value, such as passing it to useMemo or useCallback, or when you want intermediate steps to execute lazily.
5. Null Values and Errors: Do Not Let try-catch Spread Everywhere
null/undefined has been called the "billion-dollar mistake." At least TS gives us a type system, but try-catch remains a black box: you do not know what a piece of code might throw, and callers are not required to handle it.
First line of defense: optional chaining + nullish coalescing
const city = user?.address?.city ?? "Unknown";
This is the least expensive tool TS provides. Whenever it can replace nested conditionals, use it.
Second line of defense: Option / Result types
When a function might "have no value" or "fail," do not return null or throw an exception. Instead, return a type that explicitly represents success or failure. This is the Either/Option pattern from FP.
Here is an example using neverthrow (recommended for its approachable API):
import { ok, err, Result } from "neverthrow";
function parseAge(input: string): Result<number, string> {
const n = Number(input);
if (Number.isNaN(n)) return err("Not a number");
if (n < 0 || n > 150) return err("Invalid age");
return ok(n);
}
const result = parseAge("28");
result
.map((age) => age + 1)
.map((age) => `You will be ${age} next year`)
.match(
(msg) => console.log(msg), // "You will be 29 next year"
(error) => console.error(error)
);
What are the benefits?
- The type signature is documentation. The return type
Result<number, string>explicitly tells the caller that the function can fail and that failures are represented by astring. The compiler forces you to handle them. - Errors flow like data.
map,mapErr, andmatchlet you process success and failure branches in a chain withouttry-catch. - No hidden control flow. When reading the code, you know where errors can occur: every operation that may fail returns a
Resultinstead of suddenly throwing.
Similar libraries include ts-results and the Either type from fp-ts. Result provides the greatest value at API boundaries, such as parsing external input and handling network requests. If an internal pure function can guarantee that it will not throw, return its value directly; there is no need to wrap every function in Result merely for the sake of "pure FP."
6. Currying and Partial Application: Configurable Functions
Currying is the process of transforming f(a, b, c) into f(a)(b)(c): each call accepts only one argument and returns a function that accepts the next one.
Its practical purpose is argument reuse and configuration:
// Regular version
function log(level: string, message: string, time: Date) {
console.log(`[${level}] ${time.toISOString()} ${message}`);
}
// Curried version
const curriedLog = (level: string) => (message: string) => (time: Date) =>
console.log(`[${level}] ${time.toISOString()} ${message}`);
const logError = curriedLog("ERROR"); // Configure level
const logErrorNow = logError("something"); // Then configure message
logErrorNow(new Date()); // Execution occurs only after time is provided
Writing curried types manually in TS is cumbersome. In practice, partial application is more common: fix some arguments to obtain a new function.
function makeFetcher(baseUrl: string, defaultHeaders: Record<string, string>) {
return (path: string) =>
fetch(`${baseUrl}${path}`, { headers: defaultHeaders });
}
const apiFetcher = makeFetcher("https://api.example.com", {
Authorization: "Bearer xxx",
});
// Business code only needs to care about path
apiFetcher("/users");
apiFetcher("/orders");
This kind of "configuration factory" pattern is everywhere in frontend engineering: axios.create(), createRouter(), and initialization functions for various SDKs are all forms of partial application at heart.
Practical advice: there is no need to curry every function. When a function has a clear division between "configuration arguments + business arguments," structure it to accept the configuration first and return the business function. That is currying at its most practical.
7. Do Not Go to Extremes: Practical FP, Not Monad Dogma
At this point, a dose of cold water is necessary.
There is a tendency in FP circles to treat "everything uses monads, everything uses functors, everything is point-free" as correctness itself. The code ends up looking like a riddle, and then gets called "elegant."
That is wrong. Code is written for people to read and only incidentally for machines to execute.
Here are several pragmatic principles:
-
map/filter/reduce+ pure functions + immutability already deliver 80% of the benefits. Master them, and you are already a better programmer. -
Use
Result/Optionat boundaries: parsing input, making network requests, and calling external SDKs. Internal pure computations do not need to be wrapped. -
Use point-free style with caution. The following may look impressive, but it is a reading nightmare:
ts // Do not write this const getResult = compose( fold, chain(parseJson), map(prop("data")), filter(isValid) ); // Write it plainly like this so everyone can understand it const getResult = (raw: string) => { const parsed = parseJson(raw); if (!parsed) return null; const valid = parsed.filter(isValid); return valid.map((p) => p.data).flatMap(fold); }; -
Readability > concision > dogmatic correctness. When an FP approach needs a paragraph of comments to explain what it does while the imperative version is immediately obvious, use the imperative version. Nobody will fire you for using a
forloop. -
Do not introduce a heavyweight tool such as
fp-tsmerely for the sake of FP unless your team can genuinely handle it. A colleague unfamiliar with it may fall into the trap of "spending an hour understanding type gymnastics before changing one line to fix a bug." A lightweight option such asneverthrowstrikes a better balance in most projects.
The criterion is simple: after adopting FP, does onboarding a new team member become easier or harder? If it becomes harder, you are practicing dogmatic FP, not practical FP.
8. FP and OOP Are Not Opposites: Use Them Together
Finally, let us clear up a common misconception: many people think choosing FP means abandoning OOP, or vice versa.
In reality, they solve problems at different levels:
- OOP excels at encapsulating state and behavior. React Class Components, although they are less common today, and domain-model entities such as
UserandOrdercan use the OOP style to organize related data and methods effectively. - FP excels at data flow and computation. FP is more elegant for processing collections, composing operations, and handling null values and errors.
In practice, most excellent TS codebases use a hybrid paradigm:
// OOP: use a class to encapsulate a domain entity's state and invariants
class ShoppingCart {
private items: Item[] = [];
add(item: Item): void {
this.items = [...this.items, item]; // Immutable update
}
// FP: expose computations through pure functional methods
total(discount: number): number {
return this.items
.filter((i) => i.active)
.map((i) => i.price)
.reduce((sum, p) => sum + p, 0) * discount;
}
}
Use OOP for encapsulation, FP for computation, I/O at the boundaries, and keep the core pure. This combination is better suited to the complexity of real-world engineering than either "pure OOP" or "pure FP."
React itself is an excellent example: the component tree is declarative, state updates are immutable (setState always receives a new object), and hooks compose higher-order functions. Yet individual business modules can still use classes for services and OOP to organize domain models. Paradigms are tools, not beliefs.
Summary
Let us review the main points:
- The practical value of FP lies in predictability, testability, and composability, not in showing off, and certainly not in producing code your colleagues cannot understand.
- Pure functions + immutability form the foundation. Push side effects to the boundaries, write core logic as pure functions, and always return a new copy when updating state.
map/filter/reduceare everyday workhorses. Combined withpipe/compose, they can express data flow elegantly. But method chaining already covers most use cases, so there is no need to force a point-free style.- Use
Result/Optionto handle null values and errors. They are especially effective at API boundaries; internal pure functions do not need to be wrapped. - Practice pragmatic FP and reject monad dogma. Readability always comes first. FP and OOP are not opposites; they are most powerful when used together.
I will close with something I often remind myself:
The best code is neither the "most functional" nor the "most object-oriented." It is code that a colleague taking it over six months later can understand within ten minutes. FP gives you tools to achieve that goal, not the other way around.
Related
Related Content
Article
What Is Vibe Coding? Will It Change How Programmers Work?
Technical trends, engineering practices, and an indie developer perspective on Vibe Coding, AI, and independent development.
Article
From Copilot to Codex: AI Coding Tools Enter the Agent Era
Technical trends, engineering practices, and indie-developer perspectives around AI, AI Agents, and Vibe Coding.
Article
Complete Technology Stack Analysis for LLM Application Development
Covers tech trends, engineering practices, and indie developer perspectives around large models, RAG, and AI.
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.