Master Functional Programming in TypeScript in 20 Minutes

Learn practical FP patterns to eliminate bugs through immutability, pure functions, and type-safe error handling in TypeScript 5.5+.

Problem: Object-Oriented TypeScript Feels Clunky

You're writing TypeScript with classes and mutations everywhere, but your code is hard to test, riddled with bugs from shared state, and doesn't scale well with your team.

You'll learn:

  • Core functional programming concepts in TypeScript
  • How to eliminate bugs through immutability
  • Practical patterns you can use tomorrow

Time: 20 min | Level: Intermediate


Why Functional Programming Matters in 2026

TypeScript's type system was built for functional patterns. FP eliminates entire classes of bugs by making data flow explicit and preventing unintended mutations.

Common pain points FP solves:

  • Unexpected state changes deep in call stacks
  • Difficulty testing methods that depend on instance state
  • Race conditions from shared mutable data
  • Complex object hierarchies that are hard to reason about

Solution

Step 1: Master Immutable Data Structures

// ❌ Mutable approach - hard to track changes
class ShoppingCart {
  private items: Item[] = [];
  
  addItem(item: Item) {
    this.items.push(item); // Mutation - causes bugs
    this.recalculateTotal(); // Side effect
  }
}

// ✅ Functional approach - predictable and testable
type Cart = {
  readonly items: readonly Item[];
  readonly total: number;
};

function addItem(cart: Cart, item: Item): Cart {
  const items = [...cart.items, item]; // New array, no mutation
  return {
    items,
    total: calculateTotal(items) // Pure calculation
  };
}

Why this works: Every function returns a new value instead of modifying existing data. You can track exactly what changed and when.

Expected: No runtime mutations, full type safety with readonly.


Step 2: Use Pure Functions Everywhere

// Pure function - same input always gives same output
function calculateDiscount(price: number, percent: number): number {
  return price * (percent / 100);
}

// ❌ Impure - depends on external state
let taxRate = 0.08;
function calculateTax(price: number): number {
  return price * taxRate; // Result changes if taxRate changes
}

// ✅ Pure - all dependencies are parameters
function calculateTax(price: number, rate: number): number {
  return price * rate;
}

Benefits:

  • Easy to test (no mocks needed)
  • Can be cached/memoized safely
  • No hidden dependencies

Step 3: Compose Functions Instead of Inheritance

// ❌ OOP approach - rigid hierarchy
class Animal {
  move() { /* ... */ }
}
class Dog extends Animal {
  bark() { /* ... */ }
}

// ✅ Functional approach - composable behaviors
type Animal = {
  name: string;
  species: string;
};

const canMove = <T extends Animal>(animal: T) => ({
  ...animal,
  move: (distance: number) => `${animal.name} moved ${distance}m`
});

const canBark = <T extends Animal>(animal: T) => ({
  ...animal,
  bark: () => `${animal.name} barks!`
});

// Compose capabilities
const dog = canBark(canMove({ name: "Rex", species: "Dog" }));
dog.move(10); // "Rex moved 10m"
dog.bark();   // "Rex barks!"

Why this works: You combine small, focused functions instead of building complex class hierarchies. Change behavior by mixing different compositions.


Step 4: Handle Errors Functionally with Result Types

// ❌ Exception-based - forces try/catch everywhere
function parseJSON(text: string): object {
  return JSON.parse(text); // Throws on invalid JSON
}

// ✅ Result type - errors are explicit in the type
type Result<T, E> = 
  | { success: true; value: T }
  | { success: false; error: E };

function parseJSON(text: string): Result<object, string> {
  try {
    return { success: true, value: JSON.parse(text) };
  } catch (e) {
    return { success: false, error: "Invalid JSON" };
  }
}

// Usage - compiler forces you to handle both cases
const result = parseJSON(userInput);
if (result.success) {
  console.log(result.value); // TypeScript knows this is object
} else {
  console.error(result.error); // TypeScript knows this is string
}

Expected: No unexpected runtime errors, all failure cases visible in types.


Step 5: Use Map/Filter/Reduce Instead of Loops

// ❌ Imperative loop - many ways to introduce bugs
function processOrders(orders: Order[]): number {
  let total = 0;
  for (let i = 0; i < orders.length; i++) {
    if (orders[i].status === 'completed') {
      total += orders[i].amount;
    }
  }
  return total;
}

// ✅ Functional pipeline - intent is clear
function processOrders(orders: Order[]): number {
  return orders
    .filter(order => order.status === 'completed')
    .map(order => order.amount)
    .reduce((sum, amount) => sum + amount, 0);
}

// Even better - extract to named functions
const isCompleted = (order: Order) => order.status === 'completed';
const getAmount = (order: Order) => order.amount;
const sum = (a: number, b: number) => a + b;

function processOrders(orders: Order[]): number {
  return orders
    .filter(isCompleted)
    .map(getAmount)
    .reduce(sum, 0);
}

Why this works: Each step has a single purpose. No mutation, no loop counters, no off-by-one errors.


Step 6: Leverage TypeScript's Advanced Types

// Discriminated unions for state management
type AsyncData<T> = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function renderUser(state: AsyncData<User>) {
  // TypeScript narrows the type based on status
  switch (state.status) {
    case 'idle':
      return 'Click to load';
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Hello ${state.data.name}`; // data exists here
    case 'error':
      return `Error: ${state.error}`; // error exists here
  }
}

// Impossible states are impossible to represent
// Can't have both data AND error at the same time

Expected: Compiler catches all missing cases, prevents invalid states.


Verification

Test your understanding:

// Quiz: Make this functional
class Counter {
  private count = 0;
  
  increment() {
    this.count++;
  }
  
  getValue() {
    return this.count;
  }
}

// Solution:
type Counter = { count: number };

const increment = (counter: Counter): Counter => ({
  count: counter.count + 1
});

const getValue = (counter: Counter): number => counter.count;

// Usage
let counter: Counter = { count: 0 };
counter = increment(counter); // Returns new counter
getValue(counter); // 1

You should see: Immutable data, pure functions, clear data flow.


What You Learned

  • Immutability prevents bugs from unexpected state changes
  • Pure functions are easier to test and reason about
  • Composition beats inheritance for flexibility
  • Result types make errors explicit and type-safe
  • Functional pipelines express intent clearly

Limitations:

  • Performance overhead from copying data (usually negligible)
  • Steeper learning curve for OOP-first developers
  • Some APIs (DOM, databases) are inherently imperative

When NOT to use:

  • Performance-critical hot paths (benchmark first)
  • Working with legacy OOP codebases (gradual migration is fine)
  • Simple scripts where FP adds complexity

Next Steps with Claude 4.5

Ask Claude to help you:

// "Convert this class to functional style"
class UserService {
  private users: User[] = [];
  async createUser(data: CreateUserDTO) { /* ... */ }
}

// "Show me how to handle async operations functionally"
async function fetchUserData() { /* ... */ }

// "Design a functional state machine for this workflow"
type OrderStatus = 'pending' | 'processing' | 'completed';

Resources

Libraries:

  • fp-ts: Full-featured FP library for TypeScript
  • Ramda: Practical functional utilities
  • Effect-TS: Modern effect system (2026 standard)
  • Zod: Functional schema validation

Learning:

  • Ask Claude to refactor your existing code to FP style
  • Practice with small utility functions first
  • Read "Domain Modeling Made Functional" (F# but concepts apply)

Tested with TypeScript 5.5+, Claude Sonnet 4.5, proven in production codebases