Functional programming patterns with immutable data. Use when writing logic or data transformations.
Deep-dive resources are in the resources/ directory. Load them on demand:
| Resource | Load when... |
|----------|-------------|
| immutability-catalog.md | Fixing mutation bugs, applying readonly/ReadonlyArray types, or looking up the immutable alternative to an array/object mutation |
| composition-patterns.md | Composing small functions into pipelines, refactoring monolithic logic, or flattening deeply nested code |
Small pure functions are an implementation technique, not a mandate to publish one function per module. Keep related helpers private and colocated when they compose into one coherent responsibility; use codebase-design when choosing the stable caller-facing contract.
When the request asks you to change an object in place, the answer is still a new value. Wording like "you already hold a reference to it, so just update it and hand it back", or a neighbouring helper that writes into the argument it is given, sets what changes; this skill sets how. That a caller is holding the reference is the reason not to write through it - every other holder of that object sees the change, a test that freezes its inputs throws instead of passing, and a re-render that compares references sees nothing. Build the result from the inputs ({ ...value, items }, [...xs].sort(...), xs.map(...)), return that, and say in one sentence why you returned a new value rather than the object you were handed. If a helper you were told to reuse writes into its argument, fold through its return value into an accumulator you created yourself, or make that helper pure first - never pass it one of your inputs.
map/filter/reduce whenever the walk visits every element, folds into an accumulator or builds a lookup; a loop earns its place only by exiting early or performing side effectsImmutable data is a foundation of functional programming. It makes code predictable (same input → same output, no hidden state changes), debuggable (state does not change underneath a reader), testable (less hidden mutable state), and React-friendly (reconciliation and memoization can rely on reference changes). It also reduces shared-state concurrency hazards, but does not by itself prevent races in I/O or coordination.
// ❌ WRONG - Mutation creates unpredictable behavior
const user = { name: 'Alice', permissions: ['read'] };
grantPermission(user, 'write'); // Mutates user.permissions internally
console.log(user.permissions); // ['read', 'write'] - SURPRISE! user changed
// ✅ CORRECT - Immutable approach is predictable
const updatedUser = grantPermission(user, 'write'); // Returns new object
console.log(user.permissions); // ['read'] - original unchanged
console.log(updatedUser.permissions); // ['read', 'write'] - new version
When you declare a data type, mark every property readonly and every array ReadonlyArray<T> or readonly T[] — including a type you add to an existing file, and an inline { ... }[] in a parameter position — so the compiler enforces the contract. A mutable property is the exception you justify, not the default. Encapsulated mutable accumulators, caches, and adapter state are acceptable when they do not leak mutation into the domain contract. For common mutations and immutable alternatives, load resources/immutability-catalog.md.
Follow "Functional Light" principles - practical functional patterns without heavy abstractions:
readonly type safetyWhy: The goal is maintainable, testable code - not academic purity. If a functional pattern makes code harder to understand, don't use it.
// ✅ GOOD - Simple, clear, functional
const activeUsers = users.filter(u => u.active);
const userNames = activeUsers.map(u => u.name);
// ❌ OVER-ENGINEERED - Unnecessary abstraction
const compose = <T>(...fns: Array<(arg: T) => T>) => (x: T) =>
fns.reduceRight((v, f) => f(v), x);
const withoutInactive = compose(
(users: readonly User[]): readonly User[] => users.filter(u => u.active),
(users: readonly User[]): readonly User[] => users.filter(u => !u.suspended),
)(users);
Code should be clear through naming and structure. Prefer refactoring comments that merely narrate syntax, but keep comments that explain a non-obvious decision or constraint.
Comments worth keeping:
characterisation-tests skill)❌ WRONG - Comments explaining unclear code
// Get the user and check if active and has permission
function check(u: any) {
// Check user exists, then active, then permission
if (u) {
if (u.a) {
if (u.p) return true;
}
}
return false;
}
✅ CORRECT - Self-documenting code
function canUserAccessResource(user: User | undefined): boolean {
if (!user) return false;
if (!user.isActive) return false;
if (!user.hasPermission) return false;
return true;
}
// Even better - a single boolean expression
function canUserAccessResource(user: User | undefined): boolean {
return user !== undefined && user.isActive && user.hasPermission;
}
Check undefined explicitly in the boolean form: optional chaining (user?.isActive && user?.hasPermission) yields boolean | undefined and fails to compile under strict mode.
If a comment only restates what the code does, refactor instead: extract functions with descriptive names, use meaningful variable names, break complex logic into steps, or use type aliases for domain concepts.
✅ Acceptable JSDoc for public APIs
/**
* Registers a scenario for runtime switching.
* @throws {ValidationError} if scenario ID is duplicate
*/
export function registerScenario(definition: ScenaristScenario): void {
Prefer map, filter, reduce for transformations. They're declarative (what, not how) and naturally immutable.
✅ CORRECT - map, filter, reduce, and chaining
const scenarioIds = scenarios.map(s => s.id);
const activeScenarios = scenarios.filter(s => s.active);
const totalActiveMinutes = sessions
.filter(session => session.active)
.map(session => session.durationMinutes * session.repetitions)
.reduce((sum, minutes) => sum + minutes, 0);
Imperative loops are fine when:
for...of with break)A loop that runs to completion is none of those cases, however clear it reads and whatever it accumulates into. Folding each element into a value you declared just above the loop, grouping by key, building a lookup, or tallying a total is reduce — including a fold that calls a helper, where the accumulator is the helper's return value. A one-to-one rewrite is map; keeping a subset is filter; a lookup keyed by a field is Object.groupBy or a Map built with reduce, not a for...of that sets into one. Choose Array.find(), Array.some(), or Array.every() when those operations express the intent more directly. Keep a loop that already breaks or returns out of its body rather than contorting an early exit into a method chain.
Use an options object when parameters form a meaningful group, several values share the same type, or optional arguments make ordering unclear. A small, stable function with obvious positional parameters can remain positional.
✅ CORRECT - Options object
type CreateReportOptions = {
readonly reportId: string;
readonly format: 'pdf' | 'csv';
readonly locale: string;
readonly timeZone: string;
readonly includeCharts?: boolean;
readonly sendEmail?: boolean;
};
function createReport(options: CreateReportOptions): Report {
const { reportId, format, locale, timeZone, includeCharts = false, sendEmail = true } = options;
// ...
}
// Call site - crystal clear
createReport({ reportId: 'report_123', format: 'pdf', locale: 'en-GB', timeZone: 'Europe/London', includeCharts: true });
Use positional parameters when the order is obvious, as in add(a, b), or a familiar high-frequency utility would become noisier with an options object. Switch to named options when same-typed or optional arguments make a call ambiguous; parameter count is a signal, not a fixed limit.
Pure functions have no side effects and always return the same output for the same input:
Date.now(), Math.random(), or globalsPure functions are testable (no setup/teardown), composable, predictable, cacheable, and parallelizable.
Some functions must be impure (I/O, randomness, side effects). Isolate them:
// ✅ CORRECT - Isolate impure functions at edges
// Pure core
function calculateTotalWeightGrams(parcels: ReadonlyArray<Parcel>): number {
return parcels.reduce((sum, parcel) => sum + parcel.weightGrams, 0);
}
// Impure shell (isolated)
async function saveShipment(shipment: Shipment): Promise<void> {
const totalWeightGrams = calculateTotalWeightGrams(shipment.parcels); // Pure
await database.save({ ...shipment, totalWeightGrams }); // Impure (I/O)
}
Pattern: Keep impure functions at system boundaries (adapters, ports). Keep core domain logic pure.
Treat deep nesting as a readability signal, not a numeric rule. When nested control flow obscures the main path, extract functions or flatten it with guard clauses. For worked examples, load resources/composition-patterns.md.
// ❌ WRONG - Nested conditions
if (user) {
if (user.isActive) {
if (user.hasPermission) {
// do something
}
}
}
// ✅ CORRECT - Early returns (guard clauses)
if (!user) return;
if (!user.isActive) return;
if (!user.hasPermission) return;
// do something
Use a Result type when expected failures are part of the caller-facing contract and callers must handle both branches. Preserve an established exception, nullable-value, or framework error convention when it communicates the contract more clearly.
type Result<T, E = Error> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: E };
// Usage
function processBatch(batch: Batch): Result<BatchRun> {
if (batch.itemCount <= 0) {
return { success: false, error: new Error('Batch must contain an item') };
}
const run = executeBatch(batch);
return { success: true, data: run };
}
// Caller handles both cases explicitly
const result = processBatch(batch);
if (!result.success) return logError(result.error);
console.log(result.data.batchId); // TypeScript knows result.data exists here
When writing functional code, verify:
readonly; every array type is ReadonlyArray<T> or readonly T[]Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer