| name | functional |
| description | Functional programming patterns with immutable data. Use when writing logic or data transformations. |
Functional Patterns
Core Principles
- No data mutation - immutable structures only
- Pure functions wherever possible
- Composition over inheritance
- No comments - code should be self-documenting
- Array methods over loops
- Options objects over positional parameters
Why Immutability Matters
Immutable data is the foundation of functional programming. Understanding WHY
helps you embrace it:
- Predictable: Same input always produces same output (no hidden state
changes)
- Debuggable: State doesn't change unexpectedly - easier to trace bugs
- Testable: No hidden mutable state makes tests straightforward
- React-friendly: React's reconciliation and memoization optimizations work
correctly
- Concurrency-safe: No race conditions when data can't change
Example of the problem:
const user = { name: "Alice", permissions: ["read"] };
grantPermission(user, "write");
console.log(user.permissions);
const user = { name: "Alice", permissions: ["read"] };
const updatedUser = grantPermission(user, "write");
console.log(user.permissions);
console.log(updatedUser.permissions);
Functional Light
We follow "Functional Light" principles - practical functional patterns without
heavy abstractions:
What we DO:
- Pure functions and immutable data
- Composition and declarative code
- Array methods over loops
- Type safety and readonly
What we DON'T do:
- Category theory or monads
- Heavy FP libraries (fp-ts, Ramda)
- Over-engineering with abstractions
- Functional for the sake of functional
Why: The goal is maintainable, testable code - not academic purity. If a
functional pattern makes code harder to understand, don't use it.
Example - Keep it simple:
const activeUsers = users.filter((user) => user.active);
const userNames = activeUsers.map((user) => user.name);
function compose<T>(...fns: Array<(argument: T) => T>) {
return (x: T) => fns.reduceRight((value, func) => func(value), x);
}
const activeUsers = compose(
filter((user: User) => user.active),
map((user: User) => user.name),
)(users);
No Comments / Self-Documenting Code
Code should be clear through naming and structure. Comments indicate unclear
code.
Exception: JSDoc for public APIs when generating documentation.
Clear Code Examples
❌ WRONG - Comments explaining unclear code
function check(user: unknown): boolean {
return !!(
user &&
user.a &&
user.p
);
}
✅ CORRECT - Self-documenting code
function canUserAccessResource(user: undefined | User): boolean {
if (!user) {
return false;
}
if (!user.isActive) {
return false;
}
if (!user.hasPermission) {
return false;
}
return true;
}
function canUserAccessResource(user: undefined | User): boolean {
return user?.isActive && user?.hasPermission;
}
When Code Needs Explaining
If code requires comments to understand, refactor instead:
- Extract functions with descriptive names
- Use meaningful variable names
- Break complex logic into steps
- Use type aliases for domain concepts
✅ Acceptable JSDoc for public APIs
export function registerScenario(definition: ScenaristScenario): void {
}
Array Methods Over Loops
Prefer map, filter, reduce for transformations. They're declarative (what,
not how) and naturally immutable.
Map - Transform Each Element
❌ WRONG - Imperative loop
const scenarioIds = [];
for (const scenario of scenarios) {
scenarioIds.push(scenario.id);
}
✅ CORRECT - Functional map
const scenarioIds = scenarios.map((scenario) => scenario.id);
Filter - Select Subset
❌ WRONG - Imperative loop
const activeScenarios = [];
for (const scenario of scenarios) {
if (scenario.active) {
activeScenarios.push(scenario);
}
}
✅ CORRECT - Functional filter
const activeScenarios = scenarios.filter((scenario) => scenario.active);
Reduce - Aggregate Values
❌ WRONG - Imperative loop
let total = 0;
for (const item of items) {
total += item.price * item.quantity;
}
✅ CORRECT - Functional reduce
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
Chaining Multiple Operations
✅ CORRECT - Compose array methods
const total = items
.filter((item) => item.active)
.map((item) => item.price * item.quantity)
.reduce((sum, price) => sum + price, 0);
When Loops Are Acceptable
Imperative loops are fine when:
- Early termination is essential (use
for...of with break)
- Performance critical (measure first!)
- Side effects are necessary (logging, DOM manipulation)
But even then, consider:
Array.find() for early termination
Array.some() / Array.every() for boolean checks
Options Objects Over Positional Parameters
Default to options objects for function parameters. This improves readability
and reduces ordering dependencies.
Why Options Objects?
Benefits:
- Named parameters (clear what each argument means)
- No ordering dependencies
- Easy to add optional parameters
- Self-documenting at call site
- TypeScript autocomplete
Options Object Examples
❌ WRONG - Positional parameters
function createPayment(
amount: number,
currency: string,
cardId: string,
cvv: string,
saveCard: boolean,
sendReceipt: boolean,
): Payment {
}
createPayment(100, "GBP", "card_123", "123", true, false);
✅ CORRECT - Options object
interface CreatePaymentOptions {
amount: number;
cardId: string;
currency: string;
cvv: string;
saveCard?: boolean;
sendReceipt?: boolean;
}
function createPayment(options: CreatePaymentOptions): Payment {
const {
amount,
cardId,
currency,
cvv,
saveCard = false,
sendReceipt = true,
} = options;
}
createPayment({
amount: 100,
cardId: "card_123",
currency: "GBP",
cvv: "123",
saveCard: true,
});
When Positional Parameters Are OK
Use positional parameters when:
- 1-2 parameters max
- Order is obvious (e.g.,
add(a, b))
- High-frequency utility functions
function add(a: number, b: number): number {
return a + b;
}
function updateUser(user: User, changes: Partial<User>): User {
return { ...user, ...changes };
}
Pure Functions
Pure functions have no side effects and always return the same output for the
same input.
What Makes a Function Pure?
-
No side effects
- Doesn't mutate external state
- Doesn't modify function arguments
- Doesn't perform I/O (network, file system, console)
-
Deterministic
- Same input → same output
- No dependency on external state (Date.now(), Math.random(), global vars)
-
Referentially transparent
- Can replace function call with its return value
Function Examples
❌ WRONG - Impure function (mutations)
function addScenario(scenarios: Array<Scenario>, scenarioNew: Scenario): void {
scenarios.push(scenarioNew);
}
let count = 0;
function increment(): number {
count++;
return count;
}
✅ CORRECT - Pure functions
function addScenario(
scenarios: ReadonlyArray<Scenario>,
scenarioNew: Scenario,
): ReadonlyArray<Scenario> {
return [...scenarios, scenarioNew];
}
function increment(count: number): number {
return count + 1;
}
Benefits of Pure Functions
- Testable: No setup/teardown needed
- Composable: Easy to combine
- Predictable: No hidden behavior
- Cacheable: Memoization possible
- Parallelizable: No race conditions
When Impurity Is Necessary
Some functions must be impure (I/O, randomness, side effects). Isolate them:
function calculateTotal(items: ReadonlyArray<Item>): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
async function saveOrder(order: Order): Promise<void> {
const total = calculateTotal(order.items);
await database.save({ ...order, total });
}
Pattern: Keep impure functions at system boundaries (adapters, ports). Keep
core domain logic pure.
Composition Over Complex Logic
Compose small functions into larger ones. Each function does one thing well.
Benefits of Composition
- Easier to understand (each piece is simple)
- Easier to test (test pieces independently)
- Easier to reuse (pieces work in multiple contexts)
- Easier to maintain (change one piece without affecting others)
Composition Examples
❌ WRONG - Complex monolithic function
function registerScenario(input: unknown): void {
if (typeof input !== "object" || !input) {
throw new Error("Invalid input");
}
if (!("id" in input) || typeof input.id !== "string") {
throw new Error("Missing id");
}
if (!("name" in input) || typeof input.name !== "string") {
throw new Error("Missing name");
}
if (!("mocks" in input) || !Array.isArray(input.mocks)) {
throw new Error("Missing mocks");
}
}
✅ CORRECT - Composed functions
function register(scenario: Scenario): void {
registry.register(scenario);
}
function registerScenario(input: unknown): void {
register(validate(input));
}
function validate(input: unknown): Scenario {
return ScenarioSchema.parse(input);
}
const registerScenario = pipe(validate, register);
Composing Immutable Transformations
function addDiscount(order: Order, percent: number): Order {
return { ...order, total: order.total * (1 - percent / 100) };
}
function addShipping(order: Order, cost: number): Order {
return { ...order, total: order.total + cost };
}
function addTax(order: Order, rate: number): Order {
return { ...order, total: order.total * (1 + rate) };
}
function finalizeOrder(order: Order): Order {
return addTax(addShipping(addDiscount(order, 10), 5.99), 0.2);
}
Readonly Keyword for Immutability
Use readonly on all data structures to signal immutability intent.
readonly on Properties
interface Scenario {
readonly id: string;
readonly name: string;
readonly description: string;
}
interface Scenario {
id: string;
name: string;
}
ReadonlyArray vs Array
interface Scenario {
readonly mocks: ReadonlyArray<Mock>;
}
interface Scenario {
readonly mocks: Array<Mock>;
}
Nested readonly
interface Mock {
readonly method: "GET" | "POST";
readonly response: {
readonly body: ReadonlyArray<unknown>;
readonly status: number;
};
}
Why readonly Matters
- Compiler enforces immutability: TypeScript errors on mutation attempts
- Self-documenting: Signals "don't mutate this"
- Functional programming alignment: Natural fit for FP patterns
- Prevents accidental bugs: Can't accidentally mutate data
Deep Nesting Limitation
Max 2 levels of function nesting. Beyond that, extract functions.
Why Limit Nesting?
- Deeply nested code is hard to read
- Hard to test (many paths through code)
- Hard to modify (tight coupling)
- Sign of missing abstractions
Deep Nesting Examples
❌ WRONG - Deep nesting (4+ levels)
function processOrder(order: Order): void {
if (
order.items.length > 0 &&
order.customer.verified &&
order.total > 0 &&
order.payment.valid
) {
}
}
✅ CORRECT - Flat with early returns
function processOrder(order: Order): void {
if (order.items.length === 0) {
return;
}
if (!order.customer.verified) {
return;
}
if (order.total <= 0) {
return;
}
if (!order.payment.valid) {
return;
}
processValidOrder(order);
}
✅ CORRECT - Extract to functions
function canProcessOrder(order: Order): boolean {
return (
order.items.length > 0 &&
order.customer.verified &&
order.total > 0 &&
order.payment.valid
);
}
async function processOrder(order: Order): Promise<Result<OrderData>> {
if (!canProcessOrder(order)) {
return { error: new Error("Cannot process order"), success: false };
}
const validated = validateOrder(order);
return executeOrder(validated);
}
Immutable Array Operations
Complete catalog of array mutations and their immutable alternatives:
items.push(newItem);
items.pop();
items.unshift(newItem);
items.shift();
items.splice(index, 1);
items.reverse();
items.sort();
items[i] = value;
const withNew = [...items, item];
const withoutLast = items.slice(0, -1);
const withFirst = [item, ...items];
const withoutFirst = items.slice(1);
const removed = [
...items.slice(0, index),
...items.slice(index + 1),
];
const reversed = [...items].reverse();
const sorted = [...items].sort();
const updated = items.map(
(
item,
index,
) => {
return index === i ? value : item;
},
);
Common patterns:
const withoutItem = items.filter((item) => item.id !== targetId);
const replaced = items.map((item) =>
item.id === targetId ? updatedItem : item,
);
const inserted = [...items.slice(0, index), updatedItem, ...items.slice(index)];
Immutable Object Updates
user.name = "New";
Object.assign(user, { name: "New" });
const updated = { ...user, name: "New" };
Nested Updates
const updatedCart = {
...cart,
items: cart.items.map((item, index) => {
return index === targetIndex
? { ...item, quantity: updatedQuantity }
: item;
}),
};
const updatedOrder = {
...order,
items: [
...order.items.slice(0, index),
updatedItem,
...order.items.slice(index + 1),
],
};
Early Returns Over Nesting
if (user && user.isActive && user.hasPermission) {
}
if (!user) {
return;
}
if (!user.isActive) {
return;
}
if (!user.hasPermission) {
return;
}
console.log("User is valid and has permission");
Try/Catch for External APIs
interface TryCatchError {
err: unknown;
success: false;
}
type TryCatchResult<T> = TryCatchError | TryCatchSuccess<T>;
interface TryCatchSuccess<T> {
data: T;
success: true;
}
export async function tryCatch<T>(
operation: () => Promise<T>,
): Promise<TryCatchResult<T>> {
try {
const data = await operation();
return { data, success: true };
} catch (err) {
return { err, success: false };
}
}
const result = await tryCatch(() => externalApi.call());
if (!result.success) {
if (result.error instanceof MyError) {
}
return;
}
console.log(result.data);
Result Type for Error Handling
async function processPayment(payment: Payment): Promise<Result<Transaction>> {
if (payment.amount <= 0) {
return { error: new PaymentError("Invalid amount"), success: false };
}
const transaction = await executePayment(payment);
return { data: transaction, success: true };
}
const result = await processPayment(payment);
if (!result.success) {
return;
}
console.log(result.data.transactionId);
Summary Checklist
When writing functional code, verify: