Skip to content

Functional Programming Patterns in TypeScript

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

5 min read
TypeScript code showing compose and pipe functions with type annotations

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.

tstypescript
// ❌ 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.

tstypescript
// ❌ 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:

tstypescript
// 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.

tstypescript
// 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:

tstypescript
// 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.

tstypescript
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
}
tstypescript
// ❌ 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 first

Higher-Order Functions

Functions that take functions as arguments or return functions. You already use these — map, filter, reduce are higher-order functions.

tstypescript
// 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

tstypescript
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.

tstypescript
// ❌ 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

  1. Pure functions are the highest-leverage pattern — no side effects means trivial testing and safe refactoring
  2. Default to immutability — use readonly and spread operators. Mutation is an optimization, not a default.
  3. Composition over complexity — build pipelines from small functions instead of monolithic procedures
  4. Result types replace exceptions for expected failures — the type system enforces error handling
  5. Higher-order functions reduce duplication — factories and decorators create configured behavior without repetition
  6. Apply incrementally — you do not need to rewrite everything. Start with pure utility functions and compose from there
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX