A Philosophy of Software DesignCompositionLesson 9 of 13

Pure Functions and Immutability

A pure function is a function that depends only on its inputs and produces only its output. No reading from external state. No writing to external state. No side effects. Given the same input, a pure function always returns the same output.

This is not an academic concept. It is the most practical tool for writing correct, testable, debuggable code.

What Makes a Function Pure

Two rules:

  1. Deterministic: same inputs always produce the same output
  2. No side effects: the function does not modify anything outside itself
// Pure: depends only on input, returns only output
function add(a: number, b: number): number {
  return a + b;
}

function formatCurrency(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

function filterAdults(users: readonly User[]): User[] {
  return users.filter(u => u.age >= 18);
}
// Impure: reads or writes external state
let callCount = 0;
function addWithLogging(a: number, b: number): number {
  callCount++;        // side effect: mutates external state
  console.log(a, b);  // side effect: writes to console
  return a + b;
}

function getCurrentUser(): User {
  return globalState.currentUser;  // reads external state
  // Different result depending on when you call it
}

function saveUser(user: User): void {
  database.insert(user);  // side effect: writes to database
}

Why Purity Matters

Testability

Pure functions are trivially testable. No mocks. No setup. No teardown. No test database. No environment variables.

// Testing a pure function: just call it
assert.equal(add(2, 3), 5);
assert.equal(formatCurrency(1099), "$10.99");
assert.deepEqual(filterAdults([{ age: 17 }, { age: 21 }]), [{ age: 21 }]);

// Testing an impure function: setup required
beforeEach(() => { database.clear(); });
afterEach(() => { database.close(); });
test("saveUser", async () => {
  await saveUser({ name: "Alice" });
  const users = await database.getAll();
  assert.equal(users.length, 1);
});
// Slower. Flakier. Harder to parallelize. Requires infrastructure.

Predictability

A pure function cannot surprise you. If it returned 5 yesterday, it returns 5 today. If it works in your tests, it works in production. There are no hidden dependencies on time, environment, or global state that might differ between contexts.

Debuggability

When a pure function produces wrong output, the bug is in that function. Not in some global state that was modified by another thread. Not in an environment variable that changed. Not in a database row that was deleted. The inputs and the function body are everything you need to debug.

Concurrency Safety

Pure functions cannot cause race conditions because they do not share mutable state. You can call them from multiple threads, multiple processes, or multiple servers simultaneously without coordination.

Immutability in TypeScript

Immutability means values do not change after creation. Instead of modifying a value, you create a new one with the changes applied.

readonly Properties

interface User {
  readonly id: string;
  readonly name: string;
  readonly email: string;
}

const user: User = { id: "1", name: "Alice", email: "alice@example.com" };
user.name = "Bob"; // Compile error: cannot assign to readonly property

Readonly<T> Utility

// Makes all properties readonly
type ImmutableConfig = Readonly<{
  host: string;
  port: number;
  debug: boolean;
}>;

const config: ImmutableConfig = { host: "localhost", port: 3000, debug: false };
config.port = 8080; // Compile error

as const Assertions

// Without as const: type is string[]
const colors = ["red", "green", "blue"];
colors.push("purple"); // Allowed

// With as const: type is readonly ["red", "green", "blue"]
const colors = ["red", "green", "blue"] as const;
colors.push("purple"); // Compile error: push does not exist on readonly array

as const creates the narrowest possible type: literal types for values and readonly for arrays and objects. This is invaluable for configuration objects and enum-like constants.

ReadonlyArray<T> and readonly T[]

function processItems(items: readonly string[]): string[] {
  // items.push("x");    // Compile error: push does not exist
  // items[0] = "x";     // Compile error: index signature is readonly
  return items.map(item => item.toUpperCase()); // OK: creates new array
}

Data Transformation vs Mutation

The core shift: instead of modifying data in place, build new data from old data.

// MUTATION: modifies the original
function addItemMutating(cart: Cart, item: Item): void {
  cart.items.push(item);
  cart.total += item.price;
  cart.itemCount++;
}
// The original cart is changed. Every reference to it sees the change.
// If the UI is holding a reference, it might not know the cart changed.

// TRANSFORMATION: returns new data, original untouched
function addItem(cart: Cart, item: Item): Cart {
  return {
    ...cart,
    items: [...cart.items, item],
    total: cart.total + item.price,
    itemCount: cart.itemCount + 1,
  };
}
// Original cart is unchanged. New cart has the item.
// cart !== newCart, so change detection is trivial (reference equality).

This pattern scales to every data transformation:

// Remove an item: filter, do not splice
function removeItem(cart: Cart, itemId: string): Cart {
  const items = cart.items.filter(i => i.id !== itemId);
  const removed = cart.items.find(i => i.id === itemId);
  return {
    ...cart,
    items,
    total: removed ? cart.total - removed.price : cart.total,
    itemCount: items.length,
  };
}

// Update an item: map, do not assign
function updateQuantity(cart: Cart, itemId: string, quantity: number): Cart {
  const items = cart.items.map(item =>
    item.id === itemId ? { ...item, quantity } : item
  );
  return {
    ...cart,
    items,
    total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
    itemCount: items.reduce((sum, i) => sum + i.quantity, 0),
  };
}

Where Side Effects Live

A real program must have side effects. It must read files, write to databases, display output, send network requests. The question is not "how do I eliminate side effects?" but "where do I put them?"

The answer: at the edges. Pure logic in the core, side effects at the boundaries.

// EDGE: reads input (impure)
async function loadOrders(): Promise<RawOrder[]> {
  return await database.query("SELECT * FROM orders WHERE status = 'pending'");
}

// CORE: transforms data (pure)
function processOrders(orders: readonly RawOrder[]): readonly ProcessedOrder[] {
  return orders
    .filter(isValid)
    .map(calculateTotals)
    .map(applyDiscounts);
}

// EDGE: writes output (impure)
async function saveResults(orders: readonly ProcessedOrder[]): Promise<void> {
  await database.batchUpdate(orders);
  await notificationService.sendConfirmations(orders);
}

// ORCHESTRATION: connects edges to core
async function handlePendingOrders(): Promise<void> {
  const raw = await loadOrders();           // impure: read
  const processed = processOrders(raw);     // pure: transform
  await saveResults(processed);             // impure: write
}

The processOrders function is pure. It takes data and returns data. You can test it with hardcoded input, no database required. The impure parts (loadOrders, saveResults) are thin wrappers around I/O. The business logic lives in the pure core.

When Mutation Is Acceptable

Purity is a default, not a dogma. Mutation is acceptable when:

  1. Performance-critical inner loops: creating new arrays in a tight loop can cause GC pressure. Mutate locally, return the final result immutably.
  2. Builder patterns: accumulating state during construction, then freezing the result.
  3. Local mutation: mutating a variable that does not escape the function is effectively pure.
// Local mutation: effectively pure
function sum(numbers: readonly number[]): number {
  let total = 0;  // local mutable variable
  for (const n of numbers) {
    total += n;
  }
  return total;
  // `total` does not escape. No external state changed.
  // Same input always produces same output. This is pure.
}

The rule: mutable state should not be shared. If it is local to a function, mutate freely. If it is shared between functions or modules, make it immutable.

Check Your Understanding

What makes a function pure?

Not quite. The correct answer is highlighted.
The pattern of pure logic in the core and side effects at the boundaries is called 'functional core, shell.'
Not quite.Expected: imperative

What does immutability mean?

Not quite. The correct answer is highlighted.

When is mutation acceptable?

Not quite. The correct answer is highlighted.

Practice

Rewrite a shopping cart from mutations to pure transformations:

Summary

  • Pure functions: deterministic and side-effect-free. Same input, same output, always.
  • Purity enables: trivial testing, predictability, debuggability, concurrency safety.
  • Immutability in TypeScript: readonly, Readonly<T>, as const, ReadonlyArray<T>.
  • Transform, do not mutate. Create new objects with spread, new arrays with map/filter.
  • Side effects at the edges. Functional core (pure logic), imperative shell (I/O).
  • Local mutation is fine. If it does not escape the function, it is effectively pure.

Next, we put pure functions to work as data transformation stages.