Functional Programming Patterns for Cleaner Code
Practical functional programming patterns — pure functions, immutability, pipe/compose, and more — applied to everyday TypeScript and Python code.
You don’t need Haskell to write functional code. TypeScript and Python both support functional patterns that can dramatically improve readability, testability, and reliability. The key insight: treat data transformations as pipelines of small, composable functions.
Pure Functions: The Foundation
A pure function has two properties:
- Given the same inputs, it always returns the same output
- It has no side effects (no mutations, no I/O, no global state)
// Impure — depends on external state, mutates input
let taxRate = 0.2;
function calculateTotal(items: CartItem[]): number {
let total = 0;
for (const item of items) {
item.totalPrice = item.price * item.quantity; // mutation!
total += item.totalPrice;
}
return total * (1 + taxRate); // depends on external variable
}
// Pure — same inputs always produce same output, no mutations
function calculateTotal(items: readonly CartItem[], taxRate: number): number {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
return subtotal * (1 + taxRate);
}
Pure functions are trivial to test — no setup, no mocks, no teardown:
describe('calculateTotal', () => {
it('sums items and applies tax', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 3 },
];
expect(calculateTotal(items, 0.2)).toBe(42); // (20 + 15) * 1.2
});
});
Immutability: Stop Mutating Things
Mutation is the root of countless bugs. When you pass an object to a function and it mutates that object, every other reference to it is now affected. Immutability makes data flow explicit.
TypeScript: Readonly and Spread
interface User {
readonly name: string;
readonly email: string;
readonly role: 'admin' | 'user';
}
// Bad — mutates the original
function promoteUser(user: User): void {
(user as any).role = 'admin'; // casting away readonly is a crime
}
// Good — returns a new object
function promoteUser(user: User): User {
return { ...user, role: 'admin' };
}
// For arrays — never push, always spread or concat
function addItem<T>(items: readonly T[], newItem: T): T[] {
return [...items, newItem];
}
function removeItem<T>(items: readonly T[], index: number): T[] {
return items.filter((_, i) => i !== index);
}
Python: Tuples, Frozen Dataclasses, and Spread
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class User:
name: str
email: str
role: str = "user"
def promote_user(user: User) -> User:
return replace(user, role="admin")
# Tuples instead of lists for fixed collections
ALLOWED_ORIGINS = ("localhost", "example.com", "api.example.com")
# For dicts, use | (merge) instead of mutation
original = {"name": "Alice", "role": "user"}
updated = original | {"role": "admin"} # Python 3.9+
Map, Filter, Reduce: Your Core Toolkit
These three functions replace most for loops and make intent crystal clear.
interface Transaction {
id: string;
amount: number;
status: 'completed' | 'pending' | 'failed';
category: string;
}
const transactions: Transaction[] = [
{ id: '1', amount: 100, status: 'completed', category: 'food' },
{ id: '2', amount: 50, status: 'failed', category: 'transport' },
{ id: '3', amount: 200, status: 'completed', category: 'food' },
{ id: '4', amount: 75, status: 'pending', category: 'food' },
];
// Filter: keep only what matches
const completedTransactions = transactions.filter(t => t.status === 'completed');
// Map: transform each element
const amounts = completedTransactions.map(t => t.amount);
// Reduce: collapse into a single value
const totalCompleted = amounts.reduce((sum, amount) => sum + amount, 0);
// Chained — reads top to bottom as a pipeline
const totalCompletedFood = transactions
.filter(t => t.status === 'completed')
.filter(t => t.category === 'food')
.map(t => t.amount)
.reduce((sum, a) => sum + a, 0);
The Python equivalent using comprehensions and functools.reduce:
from functools import reduce
transactions = [
{"id": "1", "amount": 100, "status": "completed", "category": "food"},
{"id": "2", "amount": 50, "status": "failed", "category": "transport"},
{"id": "3", "amount": 200, "status": "completed", "category": "food"},
{"id": "4", "amount": 75, "status": "pending", "category": "food"},
]
# Pythonic: list comprehensions + sum (preferred over reduce for simple cases)
total_completed_food = sum(
t["amount"]
for t in transactions
if t["status"] == "completed" and t["category"] == "food"
)
# Group by category using a dict comprehension + reduce
from itertools import groupby
from operator import itemgetter
sorted_txns = sorted(transactions, key=itemgetter("category"))
by_category = {
cat: list(txns)
for cat, txns in groupby(sorted_txns, key=itemgetter("category"))
}
Pipe and Compose: Build Pipelines
When chains get long, break them into named steps using pipe or compose:
// A simple pipe function — left to right execution
function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
return (arg: T) => fns.reduce((result, fn) => fn(result), arg);
}
// Each step is a small, testable, reusable function
const removeInactive = (users: User[]) => users.filter(u => u.isActive);
const sortByName = (users: User[]) => [...users].sort((a, b) => a.name.localeCompare(b.name));
const takeTop = (n: number) => (users: User[]) => users.slice(0, n);
// Compose the pipeline
const getTopActiveUsers = pipe(
removeInactive,
sortByName,
takeTop(10),
);
// Use it
const topUsers = getTopActiveUsers(allUsers);
Each function in the pipeline does one thing, is independently testable, and reads left-to-right.
Python Pipe
from functools import reduce
from typing import Callable, TypeVar
T = TypeVar("T")
def pipe(*fns: Callable[[T], T]) -> Callable[[T], T]:
return lambda arg: reduce(lambda result, fn: fn(result), fns, arg)
remove_inactive = lambda users: [u for u in users if u.is_active]
sort_by_name = lambda users: sorted(users, key=lambda u: u.name)
take_top_10 = lambda users: users[:10]
get_top_active_users = pipe(remove_inactive, sort_by_name, take_top_10)
Avoiding Side Effects: Push I/O to the Edges
The functional approach to side effects is simple: push them to the boundary of your system. Keep your core logic pure, and handle I/O (database, API, filesystem) at the edges.
// Core logic — pure, no I/O
function calculateDiscount(order: Order, loyaltyPoints: number): number {
if (loyaltyPoints > 1000) return 0.15;
if (order.total > 200) return 0.10;
if (order.items.length > 5) return 0.05;
return 0;
}
function applyDiscount(order: Order, discount: number): Order {
return {
...order,
total: order.total * (1 - discount),
discountApplied: discount,
};
}
// Boundary — handles I/O, calls pure functions
async function processOrder(orderId: string): Promise<void> {
// I/O: fetch data
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
// Pure: compute
const discount = calculateDiscount(order, user.loyaltyPoints);
const updatedOrder = applyDiscount(order, discount);
// I/O: save results
await db.orders.save(updatedOrder);
await emailService.sendReceipt(user.email, updatedOrder);
}
The pure functions are trivially testable. The boundary function orchestrates I/O and is tested with integration tests.
When NOT to Be Functional
Functional patterns aren’t always the right choice:
- Performance-critical loops:
reduceandmapcreate intermediate arrays. Aforloop with mutation can be faster when processing millions of items. - Complex state machines: Sometimes mutation is the clearest expression of state transitions.
- DOM manipulation: The browser DOM is inherently mutable. Fight that and you’ll lose.
The goal isn’t functional purity — it’s writing code that’s easy to understand, test, and change. Use functional patterns where they help. Use imperative code where it’s clearer. The best code is the code that communicates its intent most effectively.