Functional Programming Patterns in TypeScript
Practical functional programming patterns for TypeScript that improve code clarity without requiring a complete paradigm shift.

Functional programming in TypeScript does not mean abandoning classes, rewriting everything with monads, or adopting a purist philosophy. It means applying specific patterns — immutability, pure functions, composition — where they reduce complexity and improve testability. These patterns coexist naturally with object-oriented code. The goal is pragmatism, not ideology.
The patterns that deliver the most value in everyday TypeScript are simpler than you think.
Pure Functions
A pure function returns the same output for the same input and causes no side effects. This single property makes a function trivially testable, safely parallelizable, and easy to reason about.
// ❌ Impure — depends on external state, mutates input
let taxRate = 0.08;
function calculateTotal(items: CartItem[]) {
let total = 0;
items.forEach(item => {
item.totalPrice = item.price * item.quantity; // mutation
total += item.totalPrice;
});
return total * (1 + taxRate); // depends on external variable
}
// ✅ Pure — all inputs explicit, no mutation, no side effects
function calculateTotal(items: readonly CartItem[], taxRate: number): number {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return subtotal * (1 + taxRate);
}Not every function can be pure — HTTP requests, database calls, and logging are inherently impure. The strategy is to push impure operations to the edges of your system and keep the core logic pure.
Immutability by Default
Mutation is the root cause of an entire class of bugs: stale closures, shared state corruption, React rendering issues, race conditions. TypeScript provides readonly to prevent mutation at the type level.
// ❌ Mutation — callers don't expect their data to change
function addDiscount(order: Order): Order {
order.total *= 0.9; // mutates the original
order.discountApplied = true;
return order;
}
// ✅ Immutable — returns a new object
function addDiscount(order: Readonly<Order>): Order {
return {
...order,
total: order.total * 0.9,
discountApplied: true,
};
}Use readonly in function parameters to enforce immutability:
// ReadonlyArray prevents push, pop, splice
function getTopScores(scores: readonly number[], limit: number): number[] {
// scores.sort() would error — readonly
return [...scores].sort((a, b) => b - a).slice(0, limit);
}
// Readonly<T> makes all properties readonly
interface Config {
host: string;
port: number;
features: string[];
}
function createServer(config: Readonly<Config>): Server {
// config.port = 8080; // Error: readonly
return new Server({ ...config });
}Function Composition
Composition builds complex operations from simple, reusable functions. Instead of a single function that does five things, you compose five functions that each do one thing.
// Pipe: left-to-right composition
function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
return (arg: T) => fns.reduce((result, fn) => fn(result), arg);
}
// Individual transformation functions
const trim = (s: string) => s.trim();
const lowercase = (s: string) => s.toLowerCase();
const slugify = (s: string) => s.replace(/\s+/g, '-');
const removeSpecialChars = (s: string) => s.replace(/[^a-z0-9-]/g, '');
// Compose them into a pipeline
const toSlug = pipe(trim, lowercase, slugify, removeSpecialChars);
toSlug(" Hello World! 123 "); // "hello-world-123"For heterogeneous types, TypeScript does not support variadic generics well in a generic pipe. Use explicit chains or libraries:
// Array processing pipeline
function processUsers(users: User[]): UserSummary[] {
return users
.filter(isActive)
.filter(hasVerifiedEmail)
.map(toUserSummary)
.sort(byLastLogin);
}
// Each function is pure, testable independently
const isActive = (user: User): boolean => user.status === 'active';
const hasVerifiedEmail = (user: User): boolean => user.emailVerified;
const toUserSummary = (user: User): UserSummary => ({
id: user.id,
name: user.name,
lastLogin: user.lastLoginAt,
});
const byLastLogin = (a: UserSummary, b: UserSummary): number =>
b.lastLogin.getTime() - a.lastLogin.getTime();Option/Result Types for Error Handling
Instead of throwing exceptions for expected failures, use result types that force callers to handle both success and failure cases.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Usage — caller must handle both cases
function parseConfig(raw: string): Result<Config, string> {
try {
const parsed = JSON.parse(raw);
if (!parsed.host || !parsed.port) {
return err('Missing required fields: host, port');
}
return ok({ host: parsed.host, port: parsed.port });
} catch {
return err('Invalid JSON');
}
}
const result = parseConfig(configString);
if (result.ok) {
startServer(result.value); // TypeScript knows value exists
} else {
console.error(result.error); // TypeScript knows error exists
}// ❌ Exception-based — nothing forces callers to handle errors
function findUser(id: string): User {
const user = db.find(id);
if (!user) throw new NotFoundError('User not found');
return user;
}
// Caller might forget try/catch — runtime explosion
// ✅ Result-based — type system enforces error handling
function findUser(id: string): Result<User, 'not-found'> {
const user = db.find(id);
if (!user) return err('not-found');
return ok(user);
}
// Caller can't access .value without checking .ok firstHigher-Order Functions
Functions that take functions as arguments or return functions. You already use these — map, filter, reduce are higher-order functions.
// Factory function — returns configured function
function createValidator<T>(rules: Array<(value: T) => string | null>) {
return (value: T): string[] => {
return rules
.map(rule => rule(value))
.filter((error): error is string => error !== null);
};
}
const validatePassword = createValidator<string>([
(pw) => pw.length < 8 ? 'Must be at least 8 characters' : null,
(pw) => !/[A-Z]/.test(pw) ? 'Must contain uppercase letter' : null,
(pw) => !/[0-9]/.test(pw) ? 'Must contain a number' : null,
]);
validatePassword('weak'); // ['Must be at least 8 characters', 'Must contain uppercase letter', 'Must contain a number']
validatePassword('StrongPass1'); // []Memoization
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>): ReturnType<T> => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = fn(...args);
cache.set(key, result);
return result;
}) as T;
}
// Expensive computation — memoize it
const computeLayout = memoize((width: number, items: number): Layout => {
// ... complex layout calculation
return { columns: Math.floor(width / 300), rows: Math.ceil(items / 3) };
});Memoization only works for pure functions. If the function depends on external state, the cached result might be stale.
Practical Functional Refactoring
These patterns shine when refactoring complex procedural code. The before/after difference is dramatic.
// ❌ Procedural — hard to test, hard to modify
async function processOrders(orders: Order[]): Report {
const report: Report = { total: 0, byStatus: {}, errors: [] };
for (const order of orders) {
try {
if (!order.items?.length) {
report.errors.push(`Order ${order.id}: no items`);
continue;
}
const total = order.items.reduce((s, i) => s + i.price * i.qty, 0);
const tax = total * 0.08;
const status = total > 1000 ? 'high-value' : 'standard';
report.total += total + tax;
report.byStatus[status] = (report.byStatus[status] ?? 0) + 1;
} catch (e) {
report.errors.push(`Order ${order.id}: ${e}`);
}
}
return report;
}
// ✅ Functional — each step is independently testable
const calculateOrderTotal = (order: Order): number =>
order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
const applyTax = (rate: number) => (amount: number): number =>
amount * (1 + rate);
const classifyOrder = (total: number): string =>
total > 1000 ? 'high-value' : 'standard';
const validateOrder = (order: Order): Result<Order, string> =>
order.items?.length ? ok(order) : err(`Order ${order.id}: no items`);
function processOrders(orders: Order[]): Report {
const results = orders.map(order => {
const validation = validateOrder(order);
if (!validation.ok) return { error: validation.error };
const total = applyTax(0.08)(calculateOrderTotal(order));
return { total, status: classifyOrder(total) };
});
return {
total: results.reduce((s, r) => s + ('total' in r ? r.total : 0), 0),
byStatus: results.reduce((acc, r) => {
if ('status' in r) acc[r.status] = (acc[r.status] ?? 0) + 1;
return acc;
}, {} as Record<string, number>),
errors: results.filter(r => 'error' in r).map(r => (r as any).error),
};
}Key Takeaways
- Pure functions are the highest-leverage pattern — no side effects means trivial testing and safe refactoring
- Default to immutability — use
readonlyand spread operators. Mutation is an optimization, not a default. - Composition over complexity — build pipelines from small functions instead of monolithic procedures
- Result types replace exceptions for expected failures — the type system enforces error handling
- Higher-order functions reduce duplication — factories and decorators create configured behavior without repetition
- Apply incrementally — you do not need to rewrite everything. Start with pure utility functions and compose from there


