Functional Programming: Pure Functions and Immutability
Functional programming isn't new—Lisp dates back to 1958—but it's experiencing a renaissance. Modern languages like Rust, Kotlin, and even JavaScript have embraced functional concepts. TypeScript...
Key Insights
- Pure functions—those that always return the same output for the same input with no side effects—make your code predictable, testable, and easier to reason about at scale.
- Immutability eliminates entire categories of bugs by ensuring data cannot be changed after creation, forcing you to think in terms of transformations rather than mutations.
- You don’t need to rewrite your codebase in Haskell; adopting pure functions and immutability incrementally in any language yields immediate benefits for testing, debugging, and concurrency.
The Functional Paradigm Shift
Functional programming isn’t new—Lisp dates back to 1958—but it’s experiencing a renaissance. Modern languages like Rust, Kotlin, and even JavaScript have embraced functional concepts. TypeScript codebases increasingly favor immutable patterns. React’s entire mental model is built on pure functions of state.
Why the shift? Imperative programming tells the computer how to do something step by step, mutating state along the way. This works fine for small programs, but as systems grow, tracking what changed where becomes a nightmare. Functional programming flips this: you describe what you want through transformations of data, minimizing the moving parts that can break.
The two foundational concepts—pure functions and immutability—aren’t academic abstractions. They’re practical tools that reduce bugs, simplify testing, and make concurrent code safe. Let’s dig into both.
Pure Functions: The Building Blocks
A pure function has two properties:
- Deterministic: Given the same inputs, it always returns the same output.
- No side effects: It doesn’t modify external state, perform I/O, or depend on anything outside its arguments.
This property is called referential transparency—you can replace any function call with its result without changing program behavior. This makes reasoning about code dramatically simpler.
Here’s the difference in practice:
// Impure: depends on external state
let taxRate = 0.08;
function calculateTotal(items) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal + (subtotal * taxRate); // depends on external variable
}
// What if taxRate changes between calls?
// What if another function modifies taxRate?
// Good luck debugging that.
// Pure: all dependencies are explicit
function calculateTotal(items, taxRate) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal + (subtotal * taxRate);
}
// Same inputs always produce the same output.
// No hidden dependencies. No surprises.
calculateTotal([{ price: 100 }, { price: 50 }], 0.08); // Always 162
The pure version is longer by one parameter, but it’s honest about its dependencies. You can call it from anywhere, in any order, without worrying about global state. This explicitness is a feature, not a burden.
Immutability: Data That Never Changes
Immutability means that once you create a data structure, you never modify it. Instead, you create new data structures with the changes you need.
Mutation causes bugs because it introduces temporal coupling—the order of operations matters, and the same variable means different things at different times. Consider this:
// Mutable: order matters, bugs hide
function processUsers(users) {
users.sort((a, b) => a.name.localeCompare(b.name)); // mutates original!
return users.filter(u => u.active);
}
const allUsers = [{ name: 'Zoe', active: true }, { name: 'Alex', active: false }];
const activeUsers = processUsers(allUsers);
console.log(allUsers); // Surprise! allUsers is now sorted too.
The caller didn’t expect allUsers to change. This is a common source of bugs, especially when data is passed through multiple functions or shared across components.
// Immutable: original data is preserved
function processUsers(users) {
return [...users]
.sort((a, b) => a.name.localeCompare(b.name))
.filter(u => u.active);
}
const allUsers = [{ name: 'Zoe', active: true }, { name: 'Alex', active: false }];
const activeUsers = processUsers(allUsers);
console.log(allUsers); // Unchanged: [{ name: 'Zoe', ... }, { name: 'Alex', ... }]
For nested objects, you need to be more careful:
// Shallow copy isn't enough for nested data
const original = { user: { name: 'Alex', settings: { theme: 'dark' } } };
// Wrong: nested objects are still shared
const shallow = { ...original };
shallow.user.name = 'Zoe'; // Mutates original.user too!
// Right: deep immutable update
const updated = {
...original,
user: {
...original.user,
name: 'Zoe'
}
};
For complex nested updates, libraries like Immer (JavaScript) or dataclasses with replace (Python) make this ergonomic. In languages like Rust, immutability is the default—you must explicitly opt into mutation with mut.
Practical Benefits: Testing, Debugging, and Concurrency
Pure functions transform testing from an exercise in mock management to straightforward input/output verification:
// Testing a pure function: trivial
function applyDiscount(price, discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Invalid discount');
}
return price * (1 - discountPercent / 100);
}
// Tests are just input → output assertions
describe('applyDiscount', () => {
it('applies 20% discount correctly', () => {
expect(applyDiscount(100, 20)).toBe(80);
});
it('handles zero discount', () => {
expect(applyDiscount(100, 0)).toBe(100);
});
it('rejects invalid discount', () => {
expect(() => applyDiscount(100, -5)).toThrow('Invalid discount');
});
});
No mocks. No setup. No teardown. No test database. No dependency injection framework. Just call the function and check the result.
Compare this to testing code that reads from a database, modifies global state, or depends on the current time. You’d need mocks, stubs, and careful test isolation. Pure functions eliminate that complexity entirely.
For debugging, pure functions give you reproducibility. If a function produces wrong output, you can capture the inputs and reproduce the bug deterministically. No need to recreate the exact sequence of state mutations that led to the problem.
For concurrency, immutability eliminates race conditions by design. If data can’t change, threads can’t conflict over who modifies it. This is why functional languages like Erlang and Clojure excel at concurrent systems, and why modern frameworks increasingly favor immutable state.
Composing Pure Functions
Small, pure functions become powerful when composed together. Function composition lets you build complex transformations from simple, testable pieces.
// Individual pure functions
const removeInactive = users => users.filter(u => u.active);
const sortByName = users => [...users].sort((a, b) => a.name.localeCompare(b.name));
const extractEmails = users => users.map(u => u.email);
const lowercase = strings => strings.map(s => s.toLowerCase());
// Compose them into a pipeline
function pipe(...fns) {
return (input) => fns.reduce((acc, fn) => fn(acc), input);
}
const getActiveUserEmails = pipe(
removeInactive,
sortByName,
extractEmails,
lowercase
);
// Usage
const users = [
{ name: 'Zoe', email: 'ZOE@EXAMPLE.COM', active: true },
{ name: 'Alex', email: 'ALEX@EXAMPLE.COM', active: false },
{ name: 'Sam', email: 'SAM@EXAMPLE.COM', active: true }
];
getActiveUserEmails(users);
// ['sam@example.com', 'zoe@example.com']
Each function does one thing. Each is independently testable. The pipeline reads top-to-bottom as a description of the transformation. Adding a new step—say, filtering out certain email domains—means adding one line, not refactoring a monolithic function.
Managing Side Effects in the Real World
Real applications need side effects. You have to read files, query databases, make HTTP requests, and write logs. The goal isn’t to eliminate side effects but to isolate them.
The pattern is often called functional core, imperative shell: keep your business logic pure, and push side effects to the edges of your system.
// Before: side effects mixed with logic
async function processOrder(orderId) {
const order = await db.getOrder(orderId); // side effect
const discount = order.customer.isPremium ? 0.1 : 0;
const total = order.items.reduce((sum, i) => sum + i.price, 0);
const finalTotal = total * (1 - discount);
await db.updateOrder(orderId, { total: finalTotal }); // side effect
await emailService.send(order.customer.email, `Your total: ${finalTotal}`); // side effect
return finalTotal;
}
// After: pure core, impure shell
// Pure function: all business logic, no I/O
function calculateOrderTotal(order) {
const discount = order.customer.isPremium ? 0.1 : 0;
const subtotal = order.items.reduce((sum, i) => sum + i.price, 0);
return subtotal * (1 - discount);
}
// Impure shell: orchestrates I/O around pure core
async function processOrder(orderId) {
const order = await db.getOrder(orderId);
const finalTotal = calculateOrderTotal(order); // pure!
await db.updateOrder(orderId, { total: finalTotal });
await emailService.send(order.customer.email, `Your total: ${finalTotal}`);
return finalTotal;
}
Now calculateOrderTotal is trivially testable—pass in an order object, get back a number. The complex business logic (discounts, calculations, edge cases) lives in pure functions. The shell just wires up I/O.
Getting Started: Incremental Adoption
You don’t need to rewrite your codebase. Start small:
-
Extract pure functions from impure ones. When you see a function doing calculation and I/O, split it. Move the calculation into a pure function.
-
Stop mutating function arguments. Return new data instead of modifying inputs. Use spread operators,
map,filter, andreduceinstead ofpush,splice, and direct property assignment. -
Make dependencies explicit. If a function uses global state, pass that state as a parameter instead. This immediately makes the function more testable.
-
Use immutability helpers. In JavaScript, consider Immer for complex state updates. In Python, use
dataclasseswithfrozen=True. In TypeScript, usereadonlymodifiers. -
Adopt a linter rule. ESLint’s
no-param-reassigncatches accidental mutation. TypeScript’s strict mode helps enforce immutability.
The benefits compound. As more of your codebase becomes pure, testing becomes easier, bugs become more reproducible, and refactoring becomes safer. You don’t need a functional programming language—you need functional programming discipline.
Start with your next function. Make it pure.