
Implementation Functional Patterns
- 70 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
implementation-functional-patterns is a Claude Code skill in the AI & Agent Building category.
Key points
- implementation-functional-patterns
- AI & Agent Building
- AI-coding skill
Implementation Functional Patterns by the numbers
- 70 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,700 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill implementation-functional-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 70 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with implementation-functional-patterns.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when implementation-functional-patterns is a claude code skill in the ai & agent building category.
What you get
Structured output aligned to implementation-functional-patterns: implementation-functional-patterns; AI & Agent Building; AI-coding skill.
Files
TypeScript Functional Patterns
Implementation reference for the functional shapes that supersede the Gang of Four catalog in idiomatic TypeScript. Sibling to `implementation-design-patterns`: read that one when the answer is a class, this one when the answer is a function, a tagged union, or a small data structure.
TypeScript has first-class functions, discriminated unions, structural typing, and zero ceremony around closures. That means the 22 GoF patterns — written in the catalog as class hierarchies because the source material targets Java/C# — collapse to far fewer functional shapes in real TS code. Several patterns share a functional answer: tagged unions cover State, Visitor, and Composite; factory functions cover Factory Method, Abstract Factory, Prototype, and Memento; event emitters cover Mediator and Observer; wrapper functions cover Adapter and Facade. This skill names those collapses, the placement rules they imply, and the performance trade-offs.
When to Apply
- Refactoring a Factory class hierarchy, an
AbstractFactoryreturning families of products, or a class withclone()/ Memento save-restore - Refactoring a Singleton class with
private constructor+static getInstance - Refactoring a mutable Builder class for a configuration object with many optional fields
- Refactoring a Strategy / Template Method / Bridge class hierarchy where variation is a single method
- Replacing a Chain of Responsibility class chain or Decorator wrapper-class stack
- Replacing a custom Iterator class with stream methods, generators, or lazy iterator helpers
- Replacing a Command class with a closure stored in a queue (when undo/serialization is not required)
- Replacing an Adapter / Facade class that exists only to forward calls or hide subsystem orchestration
- Replacing a Proxy class with native JS
Proxy(for transparent interception) or an HOF wrapper (for selective wrapping) - Replacing a Flyweight factory class with a
Map/WeakMapcache + factory function - Replacing a State / Visitor / Composite class hierarchy with a discriminated union + exhaustive
matchfunction - Replacing a Mediator / Observer class with an event emitter or reactive signal
- Reviewing TSX where lambdas appear inline in JSX, hook deps, or
memo'd child props — placement controls identity - Recognizing imperative
forloops with mutated accumulators that would read more honestly asreduce,Object.groupBy, orflatMap
Rule Categories
| # | Category | Impact | Rules | Theme |
|---|---|---|---|---|
| 1 | Creational alternatives (create) | HIGH | 3 | Factory functions, module-scope singletons, fluent immutable builders |
| 2 | Higher-order functions (hof) | HIGH | 1 | Pass a function instead of a class |
| 3 | Pipelines & composition (pipe) | HIGH | 2 | Compose small functions: pipe (data flow), compose (wrapper layering) |
| 4 | Stream methods (stream) | HIGH | 4 | map/filter/flatMap/reduce, lazy iteration, single-pass chains |
| 5 | Wrappers (wrap) | HIGH | 2 | Wrapper functions for Adapter/Facade; native Proxy or HOF for Proxy |
| 6 | Caching & sharing (cache) | HIGH | 1 | Map/WeakMap + factory function over Flyweight class |
| 7 | Pattern matching (match) | HIGH | 1 | Discriminated unions + exhaustive match for State/Visitor/Composite |
| 8 | Signals & event emitters (signal) | HIGH | 1 | Event emitter / signal for Mediator/Observer |
| 9 | Placement & identity (place) | HIGH | 1 | Where the lambda lives controls behavior |
| 10 | Closures as data (closure) | MEDIUM | 1 | Functions that carry their state |
17 rules across 10 categories, covering all 22 GoF patterns (some patterns share a functional answer — see the GoF → Functional Map below).
GoF → Functional Map
The full mapping from each Gang of Four pattern to its functional answer in idiomatic TS. Read this table to find the rule for a specific pattern; read the categories above to find rules by functional technique.
| GoF Group | GoF Pattern | Functional Equivalent | Rule |
|---|---|---|---|
| Creational | Factory Method | Function returning tagged object | `create-factory-function-over-factory-classes` |
| Abstract Factory | Function returning record of constructors | covered by factory-function rule | |
| Builder | Object literal + Partial<T>, or fluent immutable | `create-fluent-immutable-builder` | |
| Prototype | structuredClone / spread / Immer produce | covered by factory-function rule | |
| Singleton | Module-scope const, lazy ??= memo | `create-module-scope-over-singleton` | |
| Structural | Adapter | Wrapper function translating shape | `wrap-function-over-adapter-and-facade` |
| Bridge | HOF parametrized by implementation | covered by `hof-lambda-as-strategy` | |
| Composite | Discriminated union + recursive fn | covered by tagged-union rule | |
| Decorator | compose(withA, withB, withC)(target) | `pipe-compose-over-decorator` | |
| Facade | Single high-level function hiding subsystem | covered by wrap-function rule | |
| Flyweight | Map/WeakMap + factory function | `cache-weakmap-over-flyweight` | |
| Proxy | Native Proxy or HOF wrapper | `wrap-proxy-native-or-hof` | |
| Behavioral | Chain of Responsibility | pipe(fn1, fn2) / array fold | `pipe-pipeline-over-chain-of-responsibility` |
| Command | Closure () => void | `closure-as-command` | |
| Iterator | Stream methods + generators | `stream-flatmap-over-nested-loops`, `stream-reduce-over-imperative-accumulation`, `stream-lazy-iteration-for-large-or-infinite`, `stream-prefer-single-pass-over-chained-passes` | |
| Mediator | Event emitter / signal | `signal-event-emitter-over-mediator-and-observer` | |
| Memento | Immutable snapshot via structuredClone | covered by factory-function rule | |
| Observer | Event emitter / signal / RxJS | covered by event-emitter rule | |
| State | Discriminated union + exhaustive match | `match-tagged-union-over-state-visitor-composite` | |
| Strategy | Lambda | `hof-lambda-as-strategy` | |
| Template Method | HOF taking step callback | covered by hof-lambda rule | |
| Visitor | Discriminated union + match function | covered by tagged-union rule |
How to Use
1. Find the pattern. If you know which GoF pattern you'd reach for, look it up in the GoF → Functional Map above and read the linked rule. If you only know the symptom (loop with accumulator, class returning class, three setters), the Quick Reference below groups rules by functional technique. 2. Read "When NOT to apply". Every rule lists the narrow conditions where the class form still wins. The skill is a complement to the parent skill, not a repudiation — keep the class when serialization, runtime introspection, typed inter-pattern relations, cross-cutting state, framework integration, or lifecycle ownership demands it. 3. Check identity assumptions in TSX. If the code lives in a TSX file or runs inside a hook, also read the place-* rules — placement decides whether memo, useEffect, and React Compiler can do their jobs. 4. Mind the performance. Every rule has a ### Performance trade-offs section quantifying time, memory, and allocation costs. Most functional forms are performance-equivalent to the class form; a few (chained streams, flatMap) have real constant-factor costs that matter in hot paths.
Quick Reference
1. Creational alternatives
- `create-factory-function-over-factory-classes` — Function returning a tagged object instead of a Factory class hierarchy. Covers Factory Method, Abstract Factory, Prototype, and Memento. "I'd write `new FooFactory().create()` — but a function returning the tagged object is shorter and tree-shakes." — HIGH
- `create-module-scope-over-singleton` —
export const x = …or lazy??=instead ofclass X { private static instance; getInstance() }. ES modules ARE singletons; the class form is anti-idiom in TS. "I need exactly one of these — config, DB client, logger." — HIGH - `create-fluent-immutable-builder` — Object literal +
Partial<T>for simple cases; fluent immutable (each method returns a new builder) for type-state-tracked DSLs. "My constructor has 10 parameters / my Builder class has 8 setters." — HIGH
2. Higher-order functions
- `hof-lambda-as-strategy` — Pass a comparator/predicate/transformer lambda instead of defining a Strategy class. Also covers Template Method (HOF with step callback) and Bridge (HOF parametrized by implementation). "My Strategy interface has one method." — HIGH
3. Pipelines & composition
- `pipe-pipeline-over-chain-of-responsibility` —
pipe(validate, authorize, parse)(req)or an array fold of handlers, instead of a linked list ofHandlerclasses. "My CoR chain handlers each do one transform and pass the result along." — HIGH - `pipe-compose-over-decorator` —
compose(withLogging, withCache, withAuth)(handler)— each wrapper is(handler) => handler, not aDecoratorclass. Right-to-left order reads top-down like the class stack. "I want to add logging + caching + auth around this handler." — HIGH
4. Stream methods
- `stream-flatmap-over-nested-loops` —
.flatMapfor one-to-many transforms instead offor+pushormap().reduce(concat). "For each user, expand to all their orders, then collect." — HIGH - `stream-reduce-over-imperative-accumulation` —
reduce/Object.groupBy/Map.groupByinstead oflet acc = …; for (…) acc[…] = …. The most common functional pattern in real TS: sums, counts, indexes, histograms. "I'm building up a total / index / grouped map in a loop." — HIGH - `stream-lazy-iteration-for-large-or-infinite` — Generators or TC39 Iterator helpers (
Iterator.from(arr).filter(p).take(10).toArray()) when only a prefix of results is needed. O(matched-needed) instead of O(n). "First N matches from a huge or unbounded source." — HIGH - `stream-prefer-single-pass-over-chained-passes` — Collapse
.filter().map().filter()into onereduceorfor-ofwhen the input is large or the chain is hot. Three passes → one; halves peak memory; 2–5× faster in measured hot paths. "This chain runs on every request / render over thousands of items." — HIGH
5. Wrappers
- `wrap-function-over-adapter-and-facade` — Wrapper function translating one interface to another (Adapter) or hiding subsystem orchestration (Facade). "I'd write `class XAdapter implements Y` whose every method is one-line forwarding." — HIGH
- `wrap-proxy-native-or-hof` — Native
Proxyfor transparent dynamic-key interception, HOF wrapper for selective method-level wrapping. "I want lazy loading / access control / interception without a Proxy class." — HIGH
6. Caching & sharing
- `cache-weakmap-over-flyweight` —
WeakMap/Mapcache + factory function instead of Flyweight factory class.WeakMapauto-cleans when keys go out of scope. "I'm allocating millions of similar objects with shared state." — HIGH
7. Pattern matching
- `match-tagged-union-over-state-visitor-composite` — Discriminated union +
switchon tag +assertNever— covers State (class per state), Visitor (double dispatch), and Composite (Leaf/Branch). Probably the single highest-payoff functional pattern in TS. "My class has a giant switch on `kind` / I have N state classes / I'd write a Visitor over my AST." — HIGH
8. Signals & event emitters
- `signal-event-emitter-over-mediator-and-observer` — Event emitter (
mitt,EventEmitter) or reactive signal (Solid, Preact signals, Zustand) instead of Subject/Observer classes or central Mediator class. "Many objects need to react when one changes / form fields need to coordinate / cross-component events." — HIGH
9. Placement & identity
- `place-module-scope-pure-transformers` — Put pure transformer lambdas at module scope (stable identity, reusable, tree-shakable). Nest them inside a function only when they capture something. "This `(s) => s.toLowerCase()` doesn't need to be in the component body." — HIGH
10. Closures as data
- `closure-as-command` — Store a
() => voidclosure in the queue/history/callback list instead of a Command class withexecute(). "I need a queue of deferred operations and never need to inspect or serialize them." — MEDIUM
How to Choose: Class vs Function
The class form (see `implementation-design-patterns`) earns its overhead when at least one of these is true:
- Serialization — Commands or Mementos that must survive a process restart or cross a wire
- Runtime registry / introspection — the system enumerates known strategies, displays them in a picker, or attaches metadata
- Typed inter-pattern relations — Visitor over an AST where node types reference each other, State machine where states reference each other, Mediator with typed roles
- Cross-cutting state — the "variation" carries its own configuration, lifecycle, or invariants beyond the single call
- Stable identity for `instanceof` — exhaustive matching on a finite set of named classes (rare; discriminated unions usually win)
- Lifecycle ownership — the object owns a resource (connection, file handle, disposable) and
using/Symbol.disposeintegration matters - Framework integration — ORMs, DI containers, decorator-based libraries, RxJS class-based services expect classes
Otherwise, default to the function (or tagged union, or data structure). The class wraps the value in ceremony that earns nothing.
References
1. MDN — `Array.prototype` 2. TC39 — Iterator Helpers proposal 3. Mostly Adequate Guide to Functional Programming (Brian Lonsdorf) 4. TC39 — Pipeline Operator proposal 5. MDN — Closures 6. TS Handbook — Discriminated Unions 7. MDN — `Proxy` 8. MDN — `WeakMap` 9. MDN — `structuredClone`
TypeScript Functional Patterns
Version 0.3.0 MDN / TC39 / Mostly Adequate Guide May 2026
---
Abstract
Implementation guide for the functional patterns that supersede or supplement Gang of Four classes in idiomatic TypeScript: higher-order functions, lambdas-as-arguments, pipelines, function composition, stream methods (map/filter/flatMap/reduce), closures-as-data, and lambda placement (module scope vs nested vs inline). Each rule names the GoF or imperative anti-pattern it replaces and states when the class form still wins (serialization, runtime registry, typed inter-pattern relations, cross-cutting state). Sibling to implementation-design-patterns — read this skill when the answer in TypeScript is a function, not a class.
---
Table of Contents
1. Creational alternatives — HIGH
- 1.1 Export a module-scope constant or lazy memo instead of a Singleton class
- 1.2 Return a tagged object from a factory function instead of a Factory class hierarchy
- 1.3 Use an object literal with optional fields, or a fluent immutable builder, instead of a mutable Builder class
2. Higher-order functions — HIGH
- 2.1 Pass a lambda instead of defining a Strategy class when variation is one function
3. Pipelines and composition — HIGH
- 3.1 Compose a request pipeline as pipe(handler, handler, handler) instead of linked Handler classes
- 3.2 Compose wrappers as compose(withCache, withLogging, withAuth)(handler) instead of Decorator classes
4. Stream methods — HIGH
- 4.1 Collapse .filter().map().filter() chains into a single pass when the input is large or the chain is hot
- 4.2 Use flatMap for one-to-many transforms instead of nested loops or map().reduce(concat)
- 4.3 Use generators or Iterator helpers for early-exit over large or infinite sequences
- 4.4 Use reduce / Object.groupBy / Map.groupBy for aggregation instead of imperative accumulators
5. Wrappers — HIGH
- 5.1 Translate or simplify an interface with a wrapper function instead of an Adapter or Facade class
- 5.2 Use the native Proxy primitive or an HOF wrapper instead of a Proxy class
6. Caching and sharing — HIGH
- 6.1 Cache shared values with a WeakMap or Map plus a factory function, not a Flyweight class
7. Pattern matching on tagged unions — HIGH
- 7.1 Model State, Visitor, and Composite as a discriminated union with an exhaustive match
8. Signals and event emitters — HIGH
- 8.1 Wire many-to-many or one-to-many communication with an event emitter or signal, not a Mediator or Observer class
9. Placement and identity — HIGH
- 9.1 Place pure transformer lambdas at module scope, not inside a component or hook
10. Closures and data-carrying functions — MEDIUM
- 10.1 Store a closure in the queue instead of a Command class when nothing inspects or serializes it
---
References
1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array 2. https://github.com/tc39/proposal-iterator-helpers 3. https://mostly-adequate.gitbook.io/mostly-adequate-guide/ 4. https://github.com/tc39/proposal-pipeline-operator 5. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{
"version": "0.3.0",
"organization": "MDN / TC39 / Mostly Adequate Guide",
"technology": "TypeScript Functional Patterns",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Implementation guide for the functional patterns that supersede or supplement Gang of Four classes in idiomatic TypeScript: higher-order functions, lambdas-as-arguments, pipelines, function composition, stream methods (map/filter/flatMap/reduce), closures-as-data, and lambda placement (module scope vs nested vs inline). Each rule names the GoF or imperative anti-pattern it replaces and states when the class form still wins (serialization, runtime registry, typed inter-pattern relations, cross-cutting state). Sibling to implementation-design-patterns — read this skill when the answer in TypeScript is a function, not a class.",
"references": [
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array",
"https://github.com/tc39/proposal-iterator-helpers",
"https://mostly-adequate.gitbook.io/mostly-adequate-guide/",
"https://github.com/tc39/proposal-pipeline-operator",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures"
],
"category": "Design Patterns"
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group pattern references.
These patterns are the functional counterparts to the Gang of Four catalog covered in `implementation-design-patterns`. In TypeScript — a language with first-class functions, structural typing, discriminated unions, and zero ceremony around closures — the idiomatic answer to many GoF shapes is a function with a lambda, a tagged union with a match, or a small data structure with a factory function, not a class hierarchy. Each rule below names the GoF pattern(s) it replaces and lists the narrow conditions under which the class form is still the right call (serialization, runtime registry, typed inter-pattern relations, cross-cutting state, framework integration, lifecycle ownership).
The 22 GoF patterns collapse to fewer rules here because several patterns share a functional answer: tagged unions replace State / Visitor / Composite; factory functions replace Factory Method / Abstract Factory / Prototype / Memento; event emitters replace Mediator / Observer; wrapper functions replace Adapter / Facade. See SKILL.md's GoF → Functional Map table for the full mapping.
---
1. Creational alternatives (create)
Impact: HIGH Description: Three rules replacing GoF's Creational catalog: factory functions returning tagged objects (Factory Method, Abstract Factory, Prototype, Memento), module-scope constants and lazy memos (Singleton), and fluent immutable or object-literal builders (Builder). Apply on any "produce an object" code that defaults to class hierarchies.
2. Higher-order functions (hof)
Impact: HIGH Description: The central move of functional design: pass a function instead of a class. Replaces Strategy, Template Method, and Bridge (HOF parametrized by implementation). Apply whenever variation is a single algorithm step and the variant has no internal state of its own.
3. Pipelines and composition (pipe)
Impact: HIGH Description: Composing small functions into bigger ones: pipe for forward data flow, compose for wrapper layering. Replaces Chain of Responsibility (linked handler classes) and Decorator (wrapper classes). Apply when each step takes the previous step's output and produces the next input, or when several cross-cutting concerns wrap the same operation.
4. Stream methods (stream)
Impact: HIGH Description: map, filter, flatMap, reduce, lazy iterators, and the big-O of how they chain. Replaces Iterator-as-a-class and most imperative for loops with an accumulator. Apply when transforming or aggregating collections; reach for lazy iteration (generators / TC39 Iterator helpers) when the upstream is large, infinite, or expensive; collapse to a single pass when the chain is hot or the data is large.
5. Wrappers (wrap)
Impact: HIGH Description: Two rules collapsing Adapter, Facade, and Proxy: a wrapper function for interface translation or subsystem simplification (Adapter, Facade), and native Proxy or HOF wrapper for transparent / selective interception (Proxy). Apply whenever you would write a class that exists only to forward calls.
6. Caching and sharing (cache)
Impact: HIGH Description: Memoization with Map or WeakMap plus a factory function — the functional answer to Flyweight (shared intrinsic state across many objects) and to general memoization needs. Apply when you'd otherwise allocate many similar objects with shared state, or when an expensive keyed computation is repeated.
7. Pattern matching on tagged unions (match)
Impact: HIGH Description: Discriminated unions plus exhaustive match functions (with assertNever) — the single highest-payoff TS functional pattern. Replaces State (class per state), Visitor (double dispatch over class hierarchies), and Composite (Leaf/Branch class trees). Apply on any code that switches on a type / kind / status field, walks a recursive structure, or implements a state machine.
8. Signals and event emitters (signal)
Impact: HIGH Description: Event emitters and reactive signals as the functional answer to Mediator (central hub for many-to-many) and Observer (one publisher → many subscribers). Apply when wiring cross-component communication, reactive state updates, pub-sub, or event-driven workflows.
9. Placement and identity (place)
Impact: HIGH Description: Where you put the lambda: module scope, function scope, or inline in JSX / a hook deps array. Placement controls referential identity (which determines memo/effect-dep behavior), closure capture (which determines correctness), tree-shakability, and per-render allocation cost. Apply on every function-definition decision in a TSX or hot-path file.
10. Closures and data-carrying functions (closure)
Impact: MEDIUM Description: Using closures to carry state with behavior — the lightweight alternative to Command and small object-with-one-method classes. Apply when the captured state is purely local and never needs to be serialized, inspected, or composed across instances.
Cache shared values with a WeakMap or Map plus a factory function, not a Flyweight class
The Flyweight pattern shares intrinsic state across many small objects to save memory — instead of every Tree carrying its own bitmap, all trees of the same species point at a shared TreeType. A model trained on GoF defaults to a FlyweightFactory class with an internal pool, an intrinsicState method, and a getFlyweight(key) accessor. In TypeScript the equivalent is a `WeakMap` or `Map` plus a factory function: the cache is the data structure, the factory is the function. The class wrapper carries no useful state and exposes no extra capability. Reach for WeakMap when the cache key is an object (entries get auto-collected when the key is unreachable); use Map for primitive keys (faster lookups, but watch for leaks).
Shapes to recognize
- A
FlyweightFactoryclass with apool: Map<string, Flyweight>field and one methodget(key)doing memoization - A "shared state" class instantiated once at startup, where every method is
pool.get(key) ?? pool.set(key, create(key)) - Code that creates millions of small near-identical objects (text glyphs, tree species, ORM model classes per row) and would benefit from interning
- A "cache singleton" wrapping a Map for typed access — that's a Flyweight in disguise
Incorrect (Flyweight class with internal pool):
type TreeType = { name: string; bitmap: Bitmap };
class TreeTypeFactory {
private pool = new Map<string, TreeType>();
getType(name: string, bitmap: Bitmap): TreeType {
const key = name;
let type = this.pool.get(key);
if (!type) {
type = { name, bitmap };
this.pool.set(key, type);
}
return type;
}
}
const factory = new TreeTypeFactory();
const trees = positions.map((pos) => ({
pos,
type: factory.getType('pine', pineBitmap),
}));Correct (Map + factory function):
type TreeType = { name: string; bitmap: Bitmap };
const treeTypes = new Map<string, TreeType>();
const getTreeType = (name: string, bitmap: Bitmap): TreeType =>
treeTypes.get(name) ?? (treeTypes.set(name, { name, bitmap }), treeTypes.get(name)!);
const trees = positions.map((pos) => ({
pos,
type: getTreeType('pine', pineBitmap),
}));Eight lines of class becomes three — the Map IS the pool, the function IS the factory method. The (treeTypes.set(name, …), treeTypes.get(name)!) idiom uses comma-expression to set-and-return; alternatively use ??= on a temporary local:
const getTreeType = (name: string, bitmap: Bitmap): TreeType => {
let t = treeTypes.get(name);
if (!t) treeTypes.set(name, t = { name, bitmap });
return t;
};When the key is an object, use `WeakMap` for auto-cleanup:
const renderCache = new WeakMap<AstNode, RenderedOutput>();
const render = (node: AstNode): RenderedOutput => {
let out = renderCache.get(node);
if (!out) {
out = computeRender(node); // expensive
renderCache.set(node, out);
}
return out;
};When an AstNode becomes unreachable elsewhere, its RenderedOutput entry is garbage-collected automatically. With a regular Map, you'd have to remember to evict — a classic memory leak source.
Common pitfalls
- `Map` cache leaks when keys are never removed. Long-lived
Map<string, T>caches accumulate entries forever unless you cap their size (LRU eviction) or explicitlydelete. For per-request caches that should be scoped to the request, attach them to the request object, not module scope. - `WeakMap` keys must be objects.
weakMap.set('some-string', value)is a TypeError. For string keys,Mapis the choice; pair with eviction policy. - Sharing a mutable cached value defeats Flyweight's invariant. The whole point is that the cached intrinsic state doesn't change per consumer. If a caller does
getTreeType('pine', …).bitmap = newBitmap, every tree of type 'pine' now uses the new bitmap. Freeze cached values (Object.freeze(...)) or document the immutability contract. - Cache key collisions.
getTreeType('pine', oakBitmap)returns the existing 'pine' entry (withpineBitmap) and silently ignores the second argument. The factory function should either reject mismatched calls or include the bitmap (or its hash) in the key. - Premature memoization. Caching takes memory and adds lookup cost. If the keyed values are cheap to create and infrequently reused, caching makes things slower and eats more memory. Benchmark before memoizing.
Performance trade-offs
- Time:
Map.getis O(1) amortized;WeakMap.getis too. The factory function call plus a lookup is comparable to a class method's overhead. - Memory: the win is in cached values vs duplicated values. Sharing one
TreeTypeacross 1M trees saves1M * sizeof(TreeType)minus the cache's own overhead. Worth it when the intrinsic state is meaningfully large (bitmaps, parsed ASTs, compiled regexes). - GC behavior:
WeakMapentries are collected when keys are unreachable.Mapentries persist until manually removed. Choose based on the value's lifecycle. - Cache replacement policy matters at scale. For unbounded
Mapcaches with a hot working set, an LRU implementation (e.g.,lru-cachepackage) prevents memory growth. For small finite key sets, plainMapis fine.
When NOT to apply (keep the Flyweight class)
- Multiple caches with shared eviction policy. When you have several pools that all need the same LRU rules, max size, and metrics, a
CacheFactoryclass encapsulating that policy is reasonable. Function factories returning{ get, set, evict }work too — pick whichever the team reads more readily - The pool itself has lifecycle. The cache must be initialized with configuration, flushed on shutdown, persisted to disk, or replaced atomically. A class with explicit
init/disposemethods is cleaner than a module-scopeMapand ad-hoc lifecycle hooks - Interfacing with a framework that expects a class. Some ORMs and dependency-injection systems expect repositories/caches to be classes with annotated decorators. Going against the grain costs more than the saved boilerplate
Related
- GoF class form: `structural-flyweight`
- For single-instance objects (Singleton): `create-module-scope-over-singleton`
- For interface translation (Adapter/Facade): `wrap-function-over-adapter-and-facade`
Reference: MDN — `WeakMap` · MDN — `Map`
Store a closure in the queue instead of a Command class when nothing inspects or serializes it
A model trained on the Command pattern will define a Command interface with execute(), a ConcreteCommand class per operation, and a CommandQueue that holds Command[]. When the queue's only operation is "call them in order and discard," a closure () => void is the same thing without the class. The closure captures its arguments by reference; the queue holds plain functions. Reach for the class form only when something downstream must look inside the command — undo (needs a paired reverse operation), serialization (must survive a process restart), introspection (logging, retry policy by op-type), or macro recording (the queue itself is a serializable script).
Shapes to recognize
- A
Commandinterface with one methodexecute()(noundo, nodescribe, nocost) - A queue or scheduler that does
queue.forEach(c => c.execute())and nothing else - Every concrete Command class has constructor params, no fields beyond those params, and an
executethat uses them once - The system is in-process only — commands never cross a wire or get persisted
Incorrect (Command class for fire-and-forget queue):
interface Command {
execute(): void;
}
class SendEmailCommand implements Command {
constructor(private to: string, private subject: string, private body: string) {}
execute() {
emailClient.send(this.to, this.subject, this.body);
}
}
class LogAuditCommand implements Command {
constructor(private userId: string, private action: string) {}
execute() {
auditLog.write({ userId: this.userId, action: this.action, at: Date.now() });
}
}
const afterCommit: Command[] = [];
afterCommit.push(new SendEmailCommand(user.email, 'Welcome', renderWelcome(user)));
afterCommit.push(new LogAuditCommand(user.id, 'signup'));
// later, on commit:
for (const c of afterCommit) c.execute();Correct (closure carries the captured arguments):
type DeferredAction = () => void;
const afterCommit: DeferredAction[] = [];
afterCommit.push(() => emailClient.send(user.email, 'Welcome', renderWelcome(user)));
afterCommit.push(() => auditLog.write({ userId: user.id, action: 'signup', at: Date.now() }));
// later, on commit:
for (const run of afterCommit) run();The captured user.email, user.id, etc. are closed over at the time of push — the same semantic as the class constructor. Adding a new deferred action is one line at the call site, not a new file.
Common pitfalls
- Capturing a mutable reference, not a value.
for (const u of newUsers) afterCommit.push(() => welcome(u))— withconstoffor-of, each iteration has its ownubinding, so the closures capture distinct users. Withvar(or a reusedletoutside the loop), every closure captures the same reference, which by call time has moved to the last user. Always useconstinfor-oforforEach; nevervarfor the loop variable. - Lost stack traces on async failures. If the closure throws asynchronously (
() => fetch(url).then(...)) and you neverawaitit, the rejection is unhandled and the originating call site is lost. The class form keeps the construction call site somewhere in its constructor — closures don't. Eitherawaiteach closure orPromise.allSettledthe lot and log failures. - Memory: closures keep their entire enclosing scope alive. A closure pushed onto
afterCommitthat usesuser.emailkeeps the entireuserobject reachable (and anything the user object references) until the closure runs and is released. For long-lived queues (background jobs, deferred-until-shutdown), this can leak. Pull out the small set of fields you actually need:() => emailClient.send(email, subject, body)not() => emailClient.send(user.email, ...). - The closure name is empty. A stack trace through
afterCommit[2]()shows an anonymous function; debugging is harder than with a named class. Use named function expressions when the queue is long-lived:afterCommit.push(function sendWelcomeEmail() { … }).
Performance trade-offs
- Time: closure call vs class method call — same on V8 once optimized.
- Memory per item: closure ≈ class instance with same captures. Both hold references to captured/passed values. Class instances additionally have a prototype chain reference (small, usually shared and free).
- GC behavior: for fire-and-forget queues this is moot — both forms get collected after
run(). For long-lived queues, the over-capture footgun above is the real risk; size your closures intentionally.
When NOT to apply (keep the Command class)
- Undo/redo: undo requires the inverse operation tied to the forward one. A
Commandclass with pairedexecute()andundo()keeps them together; closures fragment the inverse logic - Serialization: if the queue must survive a process crash, page reload, or distributed job runner, you need a serializable representation —
{ type: 'send-email', to, subject, body }plus a dispatcher. Closures can't be serialized - Introspection / logging: if "what's in the queue" must be observable — for a debug UI, retry policy by op-type, dead-letter routing — typed command objects beat opaque closures
- Macro recording: when the queue itself is the user's saved program (e.g., a keyboard-macro recorder, a CI workflow), each command needs a name and parameters for the user to inspect and edit
Related
- GoF class form: `behavioral-command`
- Higher-order-function counterpart for one-shot operations: `hof-lambda-as-strategy`
Reference: MDN — Closures
Return a tagged object from a factory function instead of a Factory class hierarchy
Four GoF patterns — Factory Method (subclass decides which product to create), Abstract Factory (produce families of related products), Prototype (clone via .clone()), and Memento (capture/restore state) — collapse to a single TS shape: a function that returns a value. For Factory Method, the value is a tagged object whose shape depends on the input. For Abstract Factory, the value is a record of constructors parametrized by the family theme. For Prototype, the "clone" is structuredClone(value) or a spread. For Memento, the snapshot is structuredClone(state). The class hierarchy of Factory → ConcreteFactoryA / B exists only because Java/C# can't return arbitrary objects from a function with a polymorphic return type — TypeScript can, so the hierarchy is ceremony.
Shapes to recognize
- An
abstract class Factory { abstract create(): Product }with subclasses overridingcreate()to return one variant - A
UIFactoryinterface withcreateButton(),createInput(),createDialog(), plusLightThemeFactoryandDarkThemeFactoryimplementing all three - A
Cloneableinterface withclone()method on every class that wants to be deep-copied - A
Mementoclass storing a snapshot of anOriginator's state, withsave()/restore()methods - Any "produce object X" code that uses inheritance to pick between concrete shapes — the inheritance is almost certainly accidental
Incorrect (Factory class hierarchy for one product family):
abstract class NotificationFactory {
abstract create(message: string): Notification;
}
class EmailNotificationFactory extends NotificationFactory {
create(message: string) { return new EmailNotification(message); }
}
class SmsNotificationFactory extends NotificationFactory {
create(message: string) { return new SmsNotification(message); }
}
class PushNotificationFactory extends NotificationFactory {
create(message: string) { return new PushNotification(message); }
}
const factory: NotificationFactory = userPrefersEmail
? new EmailNotificationFactory()
: new SmsNotificationFactory();
const n = factory.create('Welcome');Correct (factory function returning a tagged object):
type Notification =
| { kind: 'email'; message: string; to: string }
| { kind: 'sms'; message: string; phone: string }
| { kind: 'push'; message: string; deviceToken: string };
function notify(channel: Notification['kind'], message: string, recipient: string): Notification {
switch (channel) {
case 'email': return { kind: 'email', message, to: recipient };
case 'sms': return { kind: 'sms', message, phone: recipient };
case 'push': return { kind: 'push', message, deviceToken: recipient };
}
}
const n = notify(user.preferredChannel, 'Welcome', user.contact);The "subclass decides" of Factory Method becomes the switch (or a record lookup). Adding a new channel is a union variant + a case, not a new class.
Abstract Factory becomes a function returning a record:
type Theme = 'light' | 'dark';
function uiFactory(theme: Theme) {
const palette = theme === 'light' ? lightPalette : darkPalette;
return {
Button: (props: ButtonProps) => <button style={palette.button} {...props} />,
Input: (props: InputProps) => <input style={palette.input} {...props} />,
Card: (props: CardProps) => <div style={palette.card} {...props} />,
};
}
const { Button, Input, Card } = uiFactory(currentTheme);The family is a record; the theme is captured in the closure. No LightThemeFactory class with three methods needed.
Prototype and Memento collapse to `structuredClone`:
const snapshot = structuredClone(currentState); // Prototype.clone() / Memento.save()
// …mutate or replace state…
setState(snapshot); // Memento.restore()The class-based Cloneable interface and Memento save/restore methods are noise around what is a one-line standard-library call. structuredClone handles deeply-nested data, cyclic structures, Maps, Sets, typed arrays, dates — everything a hand-rolled clone() would have to handle.
Common pitfalls
- `structuredClone` doesn't clone functions, DOM nodes, or class instances with private state. Functions throw
DataCloneError. For state values, this is usually fine — most state is data. If your state holds a function reference, restructure so the function is recoverable (look up by name) rather than stored. - Factory function that returns `any` or a wide union without narrowing. If
notify('sms', ...)returnsNotificationrather thanNotification & { kind: 'sms' }, the caller can't access channel-specific fields. Use overloads or generic constraints for type-narrowed returns. - Mixing factories and `new`.
function makeUser(...) { return new User(...) }keeps the class. That's still a factory function, but you've kept the class for its identity (instanceof), validation in the constructor, or method bag. Be explicit about why — if the class has nothing the data doesn't, drop it. - `Object.create(prototype)` for Prototype pattern. This is the historic JS Prototype-pattern shape and is almost never the right answer —
structuredCloneor spread is what you want for value cloning.Object.createis for prototype-chain manipulation, which is a different concern.
Performance trade-offs
- Time: factory function call ≈ class constructor + method call. Same order; same hot-path performance.
- Memory: plain object literal
{ kind, message, ... }is smaller than a class instance with the same fields (no prototype reference per instance). For collections of thousands of products, the saving is real but rarely material. - `structuredClone` is faster than `JSON.parse(JSON.stringify(x))` (the common naïve clone) by 2–5× because it avoids string serialization, and it handles types JSON can't (Map, Set, Date, typed arrays, cycles).
- Bundle size: factory functions tree-shake; unused factory classes don't if any method is referenced. A 5-factory module with 3 unused factories ships only 2 functions with tree-shaking but all 5 classes without.
When NOT to apply (keep the class)
- Branded identity via `instanceof`. When callers downstream use
instanceof Notificationfor routing or validation, a class-based Notification carries that. A tagged union doesn't — thoughkind-based switching is the idiomatic replacement - Constructor validation invariants. A
class User { constructor(...) { if (!validEmail(email)) throw … } }enforces invariants at construction. A factory function can do the same, but classes pair naturally with private fields and readonly invariants - Method-rich values — values where you genuinely want
n.deliver(),n.retry(),n.metrics()as methods rather thandeliver(n). Often the method form is just stylistic preference; classes win only when there are several methods that meaningfully share private state - Cross-process serialization. If notifications cross a wire (queue, RPC, structured cloning postMessage), a tagged-object data type is better than a class — but if the receiver needs to reconstruct named class instances by tag, classes plus a registry are sometimes more convenient
Related
- GoF class forms collapsed: `creational-factory-method`, `creational-abstract-factory`, `creational-prototype`, `behavioral-memento`
- The match function that consumes tagged objects: `match-tagged-union-over-state-visitor-composite`
- For module-scope unique values (Singleton): `create-module-scope-over-singleton`
Reference: MDN — `structuredClone` · TS Handbook — Discriminated Unions
Use an object literal with optional fields, or a fluent immutable builder, instead of a mutable Builder class
A model trained on Effective Java's Builder pattern writes new PizzaBuilder().size(L).addTopping('cheese').setSauce('tomato').build() — a mutable builder with chained setters returning this. In TypeScript, two simpler alternatives almost always apply: (1) an object literal with optional fields — createPizza({ size: 'L', toppings: ['cheese'], sauce: 'tomato' }) — which handles the constructor-with-many-parameters problem in one line; (2) a fluent immutable builder — each method returns a new builder with the field set — which enables compile-time type-state tracking ("you must call .size() before .build()"). Reach for the mutable-setters class form only when the builder genuinely needs to be shared between threads / async chains, or when the construction is so step-heavy that the object literal becomes unreadable.
Shapes to recognize
- A class with a long list of
setX()methods all returningthis, ending inbuild() - A constructor with 5+ parameters and many call sites passing them in different orders (the telescoping-constructor smell that motivates Builder in the first place)
- A
Required<T>/Partial<T>shape where most fields are optional with sensible defaults - A DSL whose grammar enforces "you must do A before B before C" — type-state territory
Incorrect (mutable Builder class for an optional-fields case):
class PizzaBuilder {
private _size: Size = 'M';
private _toppings: string[] = [];
private _sauce: Sauce = 'tomato';
private _crust: Crust = 'regular';
size(s: Size): this { this._size = s; return this; }
addTopping(t: string): this { this._toppings.push(t); return this; }
sauce(s: Sauce): this { this._sauce = s; return this; }
crust(c: Crust): this { this._crust = c; return this; }
build(): Pizza {
return { size: this._size, toppings: this._toppings, sauce: this._sauce, crust: this._crust };
}
}
const p = new PizzaBuilder()
.size('L')
.addTopping('cheese').addTopping('mushroom')
.sauce('tomato')
.build();Correct (object literal with defaults via factory function):
type Pizza = { size: Size; toppings: string[]; sauce: Sauce; crust: Crust };
function createPizza(opts: Partial<Pizza>): Pizza {
return {
size: 'M',
toppings: [],
sauce: 'tomato',
crust: 'regular',
...opts,
};
}
const p = createPizza({ size: 'L', toppings: ['cheese', 'mushroom'] });Five lines, no class, no build() ceremony. Adding a new field is one ?: in the type and one default in the factory — the same diff as adding a setter, with less code.
For the type-state case (must call A before B before C), use a fluent immutable builder:
type QuerySelect<T> = { select: (keyof T)[] };
type QueryFrom<T> = QuerySelect<T> & { from: string };
type QueryWhere<T> = QueryFrom<T> & { where?: Filter<T> };
class Query<S extends Partial<QueryWhere<any>>> {
constructor(private readonly state: S) {}
select<T, K extends keyof T>(this: Query<{}>, fields: K[]): Query<QuerySelect<T>> {
return new Query({ select: fields });
}
from<T>(this: Query<QuerySelect<T>>, table: string): Query<QueryFrom<T>> {
return new Query({ ...this.state, from: table });
}
where<T>(this: Query<QueryFrom<T>>, filter: Filter<T>): Query<QueryWhere<T>> {
return new Query({ ...this.state, where: filter });
}
build(this: Query<QueryFrom<any>>): string {
return `SELECT ${this.state.select.join(', ')} FROM ${this.state.from}`;
}
}
const sql = new Query({})
.select<User, 'id' | 'name'>(['id', 'name'])
.from('users')
.where({ active: true })
.build();
// Compile error if you call .where() before .from(), or .build() before .from().Each this: constraint enforces that the previous step ran. new Query(...) per step gives identity-stable immutable state — safe in shared / async contexts. This pattern appears in Drizzle, Effect's Schema, fp-ts pipes, RxJS observable construction.
Common pitfalls
- Forgetting `...opts` LAST.
{ ...opts, size: 'M' }always setssize: 'M'no matter what the caller passes. The...optsmust come after defaults to allow override. (TypeScript will warn ifoptsisRequired<Pizza>, but withPartial<Pizza>it's silent.) - Mutable arrays inside defaults.
function createPizza(opts: Partial<Pizza>): Pizza { return { toppings: [], ...opts } }— every default pizza shares the same[]reference if you forget to spreadopts.toppings. Usetoppings: [...(opts.toppings ?? [])]if subsequent mutation is allowed, or freeze the defaults. - Spreading discards undefined-but-present.
{ ...defaults, size: undefined }keeps the spread default'ssize.{ ...defaults, ...opts }withopts.size = undefinedALSO keeps the spread default'ssizeonly if you use??rather than||. Subtle but bites. - Fluent immutable that mutates `this`. If a fluent method does
this.state.x = ...; return new Query(this.state), you've leaked the mutation back to the previous builder. Always copy the state when constructing the next builder. - Builder pattern when nothing demands it. Adding a builder when a 4-field record with 1 optional field would do is over-engineering. The rule of thumb: if there are <5 fields and order doesn't matter, the object literal wins; if there are >5 fields and construction has type-state, the fluent immutable wins.
Performance trade-offs
- Object literal + spread: one allocation per
createPizzacall. Defaults are merged via shallow spread (cheap). - Mutable class builder: one allocation for the builder plus one for the result. Two objects per build. The cheaper-looking method-chained syntax hides this.
- Fluent immutable builder: one allocation per step. For a 4-step build, four builder instances + the final result. Each is a shallow copy of the previous state. In hot paths (per-render, per-request) this can matter; for app-code one-time-per-something it's negligible.
- The fluent immutable form is what enables type-state tracking — every method has a different
thistype because each step returns a differentQuery<S>. That can't be done with a single mutablethis-returning class. The allocations are paying for the compile-time guarantee.
When NOT to apply (keep the mutable builder class)
- Shared state during construction. Multiple call sites contribute to building one value (the request handler attaches headers, then middleware attaches a body parser, then the route handler attaches a router). Mutating a shared builder is more natural than threading immutable state through each step
- Genuine DSL with deeply nested grammar. When you're building a CSS-in-JS engine, an ORM query language, or a UI component tree where the construction grammar is the public API, both the fluent immutable form AND the mutable form are fine — pick whichever the consumers find more readable. Builder classes can win when the grammar has lots of optional repeatable parts (
.addChild()called N times) - Existing convention in the codebase or library. If every existing builder in the project is
new Foo().setX().setY().build(), a single fluent-immutable outlier confuses readers. Convention beats marginal improvement
Related
- GoF class form: `creational-builder`
- Factories that don't need step-by-step construction: `create-factory-function-over-factory-classes`
- The
Partial<T>andRequired<T>utility types: see TS Handbook
Reference: TS Handbook — Utility Types (`Partial`, `Required`, `Pick`)
Export a module-scope constant or lazy memo instead of a Singleton class
A model trained on Java/C# defaults to class Db { private static instance; private constructor() {…}; static getInstance() { return Db.instance ??= new Db() } }. In ES modules, this is anti-idiom: modules are already singletons (evaluated once per process, cached by URL), so export const db = createDb() is a singleton. Adding a class wrapper around it makes the dependency harder to mock in tests, harder to replace with an alternate implementation, and harder to lazily initialize than the module-scope form. Reach for the class form only when the singleton must survive HMR reloads with stable identity, when initialization is genuinely lazy and must happen on a specific call site, or when you need to inject test doubles via a registration call.
Shapes to recognize
- A
class X { private static instance: X | null = null; private constructor() {…}; static getInstance(): X { … } }— every line of this is anti-idiom in TS - A "service locator" class with
register()andresolve()methods, when imports would do the job - A pattern where module-level state is wrapped in a class purely to hold a single instance — config, logger, DB client, cache, event bus
Incorrect (class with private constructor + static getInstance):
export class Logger {
private static instance: Logger | null = null;
private constructor(private readonly transport: Transport) {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger(createDefaultTransport());
}
return Logger.instance;
}
info(msg: string): void { this.transport.write({ level: 'info', msg }); }
warn(msg: string): void { this.transport.write({ level: 'warn', msg }); }
}
// Every call site:
Logger.getInstance().info('starting');Correct (module-scope const or lazy memo):
// logger.ts
const transport = createDefaultTransport();
export const logger = {
info: (msg: string) => transport.write({ level: 'info', msg }),
warn: (msg: string) => transport.write({ level: 'warn', msg }),
};
// Every call site:
import { logger } from './logger';
logger.info('starting');When initialization must be deferred (e.g., depends on environment variables loaded at runtime, or is expensive and may not be needed):
// db.ts
let _db: Db | null = null;
export const db = (): Db => _db ??= createDb({ url: process.env.DATABASE_URL! });
// Call sites:
import { db } from './db';
const users = await db().query('SELECT * FROM users');??= (nullish assignment, ES2021) makes lazy initialization one line. Or use a thunk pattern for explicit laziness:
export const db = lazy(() => createDb({ url: process.env.DATABASE_URL! }));
// where lazy<T>(f: () => T) returns a memoized thunk:
function lazy<T>(f: () => T): () => T {
let cached: T | undefined;
let initialized = false;
return () => {
if (!initialized) { cached = f(); initialized = true; }
return cached!;
};
}Common pitfalls
- HMR (hot module reload) re-evaluates modules. In dev, your "singleton" gets a fresh instance each save. Most of the time this is fine — you reload state intentionally. If you need a truly cross-reload singleton (a WebSocket connection, an opened browser tab, a started timer), cache it on
globalThis:((globalThis as any).__bus ??= createBus()). Document the leak. - Module-scope const captures import-time environment.
const apiUrl = process.env.API_URLat module top-level reads the env at import. If the env is set later (test setup, dotenv loaded after imports), you getundefined. Use lazy initialization or import the value through a function. - Circular imports of singletons.
a.tsexportsa = createA(b);b.tsexportsb = createB(a). Module-level construction sees one of them asundefinedat first evaluation. Either break the cycle structurally or use lazy initialization on at least one side. - Tests can't replace the singleton. A class-based singleton with private constructor is worse for testing — you can't subclass it cleanly, you can't
newan alternate. A module-scope export at least allowsvi.mock('./logger', () => …)or dependency injection at higher layers. Avoidimport { logger }deep inside business code; pass loggers as parameters or via context. - Singletons + multiple bundles. If your code is bundled twice (server + client, two separate library entry points), each bundle gets its own "singleton" instance. Not unique to the class form — but module-scope makes it obvious, while class-based hides it.
Performance trade-offs
- Time: identical at runtime — class
getInstance()is a function call plus a property read; module-scope import is resolved once at load and is a direct reference thereafter (cheaper, actually). - Memory: module-scope is a single object; class form is a class definition + a single instance. Difference is negligible.
- Cold-start cost: module-scope eager initialization runs at import time. If the singleton is expensive to build (
createDb(), parsing a config file), lazy memoization defers it. The class form forces lazy by default; module-scope lets you choose. - Tree-shaking: module-scope const that's not imported gets removed. Class definitions with any referenced static method tend to survive.
When NOT to apply (keep the class form)
- Cross-reload identity across HMR / SPA navigation. If the singleton owns a long-lived OS-level resource (WebSocket, IndexedDB transaction, audio context) and must survive code reloads,
globalThis+ a sentinel-keyed lookup is the pattern; whether you wrap it in a class is style preference - Polymorphic singletons — you want a kind of singleton (
Logger,MetricsLogger,NullLogger) registered at boot and looked up by name. AServiceRegistryclass can model this, though aMap<string, T>plus a register/resolve pair of functions is just as honest - Singletons that must enforce construction invariants. A class's private constructor + factory method makes it impossible to construct elsewhere. Module-scope exports trust the import — fine in app code, less fine in published libraries where users might
newsomething they shouldn't
Related
- GoF class form: `creational-singleton`
- Factory functions that create fresh objects (the non-singleton case): `create-factory-function-over-factory-classes`
- Caching arbitrary keyed values rather than a single instance: `cache-weakmap-over-flyweight`
Reference: MDN — `??=` (nullish assignment) · TC39 — Static class fields
Pass a lambda instead of defining a Strategy class when variation is one function
A model trained on Java/C# Strategy examples will reach for a Strategy interface, two ConcreteStrategy classes, and a Context that holds one. In TypeScript, the equivalent when the strategy has one method and no internal state is a function-typed parameter. The class form survives only when the strategy carries configuration, lifecycle, or is enumerated by a registry — see the Strategy pattern entry in `implementation-design-patterns` for those cases.
Shapes to recognize
- A
Strategyinterface declaring a single method - Two-to-three classes each implementing that one method with no fields
- A
Contextthat takes the strategy in its constructor and callsstrategy.method(args)in exactly one place - Domain-specific examples that almost always collapse to a lambda: comparators, predicates, formatters, validators, mappers, key-extractors
Incorrect (Strategy class for one method, no state):
interface InvoiceSortStrategy {
compare(a: Invoice, b: Invoice): number;
}
class SortByDueDate implements InvoiceSortStrategy {
compare(a: Invoice, b: Invoice) {
return a.dueDate.getTime() - b.dueDate.getTime();
}
}
class SortByAmountDesc implements InvoiceSortStrategy {
compare(a: Invoice, b: Invoice) {
return b.amount - a.amount;
}
}
class InvoiceList {
constructor(private invoices: Invoice[], private strategy: InvoiceSortStrategy) {}
sorted() {
return [...this.invoices].sort((a, b) => this.strategy.compare(a, b));
}
}
const overdue = new InvoiceList(invoices, new SortByDueDate()).sorted();Correct (lambda as the strategy):
type InvoiceComparator = (a: Invoice, b: Invoice) => number;
const byDueDate: InvoiceComparator = (a, b) => a.dueDate.getTime() - b.dueDate.getTime();
const byAmountDesc: InvoiceComparator = (a, b) => b.amount - a.amount;
function sortInvoices(invoices: Invoice[], compare: InvoiceComparator): Invoice[] {
return [...invoices].sort(compare);
}
const overdue = sortInvoices(invoices, byDueDate);The named type InvoiceComparator is the interface; named consts are the implementations; the call site reads identically to the class version with none of the ceremony. Adding a new sort order is one line, not one file.
Common pitfalls
- The strategy lambda captures mutable outer state.
let multiplier = 1; const scale: Strategy = (x) => x * multiplierlooks like a strategy but its behavior changes silently whenmultiplieris reassigned elsewhere. Either close over aconst, or accept the mutable state as an argument. - Inline strategy lambda inside JSX/loops. Passing
<Sortable comparator={(a, b) => a.x - b.x}>creates a new function identity each render. If the consumer memoizes on the comparator (e.g., caches a sorted result), the cache busts. Move the comparator to module scope or use a stable reference — see `place-module-scope-pure-transformers`. - Two strategies that look equal aren't `===` equal.
(x) => x.id === byIdand(x) => x.id === byIdare different references. Don't compare strategy lambdas for equality; identify them by a separate tag or name when you need that.
Performance trade-offs
- Time: function call vs method call is the same on modern V8; both inline equivalently in hot paths.
- Memory: a closure holding zero captures is comparable to a class instance with zero fields — both small. The class wins one field per per-call constructor invocation (the
private strategyfield); the closure wins by not allocating the wrapper at all when you pass the lambda directly. - Code size: the functional form is meaningfully smaller — ~5 lines per strategy vs ~10 for the class form. In a tree-shaken bundle, unused functional strategies disappear; unused class methods don't if the class is referenced.
When NOT to apply (keep the class)
- The strategy holds configuration shared across calls (
new TaxStrategy(region, year)whereregionandyearare reused on many invocations) - The strategy participates in a runtime registry — the system enumerates available strategies for a UI picker, plugin loader, or feature flag
- The strategy has multiple methods (
apply,undo,cost,describe) — at that point it's not a strategy, it's a small object, and a class or factory function returning an object is appropriate - The strategy is serialized (saved to disk, sent over a wire) — closures can't cross those boundaries; named class instances can be reconstructed by tag
Related
- GoF class form: `behavioral-strategy`
- Closures that carry state are not "strategies with state" — see `closure-as-command` for the data-carrying-function counterpart
Reference: Mostly Adequate Guide — Ch. 4 "Curry"
Model State, Visitor, and Composite as a discriminated union with an exhaustive match
Three GoF patterns — State (class per state with transitions), Visitor (double-dispatch over class hierarchies), Composite (Leaf and Branch classes with shared interface) — collapse to the same TypeScript shape: a discriminated union plus a function that switches on the tag. The tag (kind/type/status) makes the cases mutually exclusive at the type level; an assertNever at the end of the switch turns "I forgot a case" into a compile error. This is arguably the most consequential functional pattern in idiomatic TS — it deletes class hierarchies, replaces virtual dispatch with pattern matching, and makes adding a new operation a one-function diff rather than a class-edit per variant.
Shapes to recognize
- A
Stateinterface withtransition()/handle(), and NConcreteStateclasses each implementing it - A
Visitorinterface withvisitConcreteA(node),visitConcreteB(node), … andaccept(visitor)methods on every node class — double dispatch - A
Componentbase class withLeafandCompositesubclasses, all forwarding to children - A class with a
status: stringfield and a giant method that switches on it - A recursive function that does
if (node instanceof Branch) … else if (node instanceof Leaf) …
Incorrect (State as a class hierarchy):
abstract class ConnectionState {
abstract send(ctx: Connection, msg: string): ConnectionState;
}
class Idle extends ConnectionState {
send(ctx: Connection): ConnectionState {
return new Connecting(ctx.url);
}
}
class Connecting extends ConnectionState {
constructor(public url: string) { super(); }
send(): ConnectionState { throw new Error('not connected yet'); }
}
class Open extends ConnectionState {
constructor(public socket: WebSocket) { super(); }
send(_ctx: Connection, msg: string): ConnectionState {
this.socket.send(msg);
return this;
}
}
class Closed extends ConnectionState {
constructor(public reason: string) { super(); }
send(): ConnectionState { throw new Error(`closed: ${this.reason}`); }
}Correct (discriminated union + exhaustive match):
type ConnectionState =
| { tag: 'idle' }
| { tag: 'connecting'; url: string }
| { tag: 'open'; socket: WebSocket }
| { tag: 'closed'; reason: string };
const assertNever = (x: never): never => {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
};
function send(state: ConnectionState, msg: string): ConnectionState {
switch (state.tag) {
case 'idle': return { tag: 'connecting', url: 'wss://…' };
case 'connecting': throw new Error('not connected yet');
case 'open': state.socket.send(msg); return state;
case 'closed': throw new Error(`closed: ${state.reason}`);
default: return assertNever(state);
}
}Adding a new state ('reconnecting') is one type addition + one case — the compiler points at assertNever until every match site handles it. Adding a new operation (disconnect, keepalive, metrics) is one new function — no class edits, no double-dispatch ceremony.
The Visitor pattern collapses the same way:
type Expr =
| { tag: 'num'; value: number }
| { tag: 'add'; left: Expr; right: Expr }
| { tag: 'mul'; left: Expr; right: Expr };
const evaluate = (e: Expr): number =>
e.tag === 'num' ? e.value :
e.tag === 'add' ? evaluate(e.left) + evaluate(e.right) :
e.tag === 'mul' ? evaluate(e.left) * evaluate(e.right) :
assertNever(e);
const prettyPrint = (e: Expr): string =>
e.tag === 'num' ? String(e.value) :
e.tag === 'add' ? `(${prettyPrint(e.left)} + ${prettyPrint(e.right)})` :
e.tag === 'mul' ? `(${prettyPrint(e.left)} * ${prettyPrint(e.right)})` :
assertNever(e);Each "visitor" is just a function. Composite is the same shape with a recursive case.
Common pitfalls
- Forgotten `default: assertNever(x)`. Without it, adding a new variant fails silently — the switch returns
undefinedand you find out at runtime. Always finalize the switch withassertNever(orconst _exhaustive: never = state). - Tag field naming inconsistency. Pick one —
kind,type, ortag— and stick to it across the codebase.typecollides with the TypeScript keyword in mental parsing; many style guides recommendkindortag. - `instanceof` on the union. Once you've gone to a discriminated union, never reach for
instanceofagain — it tests the runtime class, which the union doesn't have. Tag-check is correct;instanceofis wrong. - Mutable transitions. State transitions should return a new state value, not mutate the current one.
state.tag = 'open'is illegal TypeScript on a readonly union and conceptually wrong (the union narrowed the type — you can't change its tag in place). - Class-and-tag both. Sometimes legacy code has classes that also have a
kindfield. Pick one: drop the classes and use plain object literals, or keep the classes and useinstanceof. Mixing both invites bugs.
Performance trade-offs
- Time: switch on a string tag is O(1) in V8 (often compiled to a jump table for small unions); class-based virtual dispatch is also O(1). Performance-equivalent at the per-call level.
- Memory: an object literal
{ tag: 'open', socket }is typically smaller than a class instance with the same fields — no prototype chain reference per instance, no constructor overhead. Often 16–24 bytes less per state value. - Bundle size: a discriminated-union match function tree-shakes — unused operations on a type don't get bundled. Unused methods on a class don't tree-shake if the class is exported.
- Inference cost: TypeScript compile time grows with the size of the union, but is rarely the bottleneck below ~50 variants. Beyond that, splitting the union into nested unions helps.
When NOT to apply (keep the class hierarchy)
- The variants carry per-instance lifecycle that classes model honestly (a state that owns a network connection, disposes a resource, holds a
Symbol.dispose-able). Tagged unions can still own these, but the cleanup discipline is on you, not the class - You're integrating with a framework that expects classes — UI libraries, ORMs, decorators that work on class methods, the
usingdeclaration'sSymbol.disposecontract - The variants form a rich domain with shared behavior worth inheriting (rare and almost always overstated — re-check if it's really inheritance or just "they happen to have similar fields")
- You need runtime introspection of the variant set — listing all states for a debug UI, generating documentation per state — both are possible with discriminated unions plus a registry, but classes plus reflection are sometimes more convenient
Related
- GoF class forms collapsed: `behavioral-state`, `behavioral-visitor`, `structural-composite`
- Adjacent: closures that carry state are a different shape — see `closure-as-command`
- The factory function that produces tagged values: `create-factory-function-over-factory-classes`
Reference: TS Handbook — Discriminated Unions · TS Handbook — `never`
Compose wrappers as compose(withCache, withLogging, withAuth)(handler) instead of Decorator classes
A model trained on the Decorator pattern stacks three wrapper classes — LoggingDecorator(CachingDecorator(AuthDecorator(handler))) — when the wrappers add cross-cutting behavior (logging, caching, auth, retries, metrics) without changing the wrapped object's interface. In TypeScript, the equivalent is function composition: each wrapper is a function (fn) => fn' that takes a handler and returns a wrapped one. Compose them with compose (right-to-left) or pipe (left-to-right) and apply to the base handler. Reach for the class form only when wrappers must be added or removed at runtime, when they carry per-instance state that outlives a single call, or when the wrapper type-narrows the handler's signature in a way function composition can't track.
Distinguishing compose from pipe
Both this rule and `pipe-pipeline-over-chain-of-responsibility` use function composition, but they solve different problems:
- `pipe(stepA, stepB, stepC)(input)` — pipeline of data transforms. Each step's output becomes the next step's input. The data flows forward; reading order matches execution order.
- `compose(wrapperA, wrapperB, wrapperC)(handler)` — composition of function wrappers. Each wrapper takes a handler and returns a decorated handler. By convention right-to-left:
compose(f, g, h)(x) === f(g(h(x))), so the outermost wrapper is on the left. This matches how Decorator class stacking reads top-to-bottom from outermost to innermost.
If you want decoration order to read left-to-right (innermost-first), use pipe — it just inverts the order.
Shapes to recognize
- An
AbstractDecoratorbase class that holds a reference to the wrapped object and forwards every method - Three to six
Decoratorsubclasses, each overriding one method to "do my thing then callsuper/this.wrapped" - Boot-time wiring:
new LoggingDecorator(new CachingDecorator(new AuthDecorator(realHandler)))— fixed once, never reconfigured - Each decorator is stateless: its only fields are the wrapped object and any constructor-injected config that never changes
Incorrect (three Decorator classes wrapping a handler):
type RequestHandler = (req: Request) => Promise<Response>;
abstract class HandlerDecorator {
constructor(protected wrapped: RequestHandler) {}
abstract handle(req: Request): Promise<Response>;
}
class WithAuth extends HandlerDecorator {
async handle(req: Request) {
if (!req.headers.authorization) throw new Error('unauthenticated');
return this.wrapped({ ...req, userId: decodeToken(req.headers.authorization) });
}
}
class WithCache extends HandlerDecorator {
private cache = new Map<string, Response>();
async handle(req: Request) {
const key = `${req.method}:${req.url}`;
const hit = this.cache.get(key);
if (hit) return hit;
const res = await this.wrapped(req);
if (req.method === 'GET') this.cache.set(key, res);
return res;
}
}
class WithLogging extends HandlerDecorator {
async handle(req: Request) {
const start = Date.now();
try {
return await this.wrapped(req);
} finally {
logger.info({ url: req.url, durationMs: Date.now() - start });
}
}
}
const realHandler: RequestHandler = async (req) => fetch(req.url).then((r) => r as unknown as Response);
const stack = new WithLogging(new WithCache(new WithAuth(realHandler)).handle.bind);
// ...awkward: each Decorator's .handle must be bound; can't be passed as RequestHandler directlyThe class form forces each Decorator to expose .handle rather than being a RequestHandler itself, breaks call-site interchangeability with the base type, and (in the WithCache case) hides per-instance state inside a class field that's not obviously the wrapper's responsibility.
Correct (each wrapper is `(handler) => handler`; compose them):
type RequestHandler = (req: Request) => Promise<Response>;
type Wrap = (handler: RequestHandler) => RequestHandler;
const withAuth: Wrap = (handler) => async (req) => {
if (!req.headers.authorization) throw new Error('unauthenticated');
return handler({ ...req, userId: decodeToken(req.headers.authorization) });
};
const withCache = (): Wrap => {
const cache = new Map<string, Response>(); // captured per call to withCache(), not shared
return (handler) => async (req) => {
const key = `${req.method}:${req.url}`;
const hit = cache.get(key);
if (hit) return hit;
const res = await handler(req);
if (req.method === 'GET') cache.set(key, res);
return res;
};
};
const withLogging: Wrap = (handler) => async (req) => {
const start = Date.now();
try {
return await handler(req);
} finally {
logger.info({ url: req.url, durationMs: Date.now() - start });
}
};
const compose = <T>(...wraps: ((x: T) => T)[]) => (base: T): T =>
wraps.reduceRight((acc, w) => w(acc), base);
const realHandler: RequestHandler = async (req) => fetch(req.url).then((r) => r as unknown as Response);
const handle = compose(withLogging, withCache(), withAuth)(realHandler);
// Reads top-down: log around cache around auth around realHandler — same as the class form.Each wrapper is interchangeable with the base type (it returns a RequestHandler, so it can be passed anywhere one is expected). Per-instance state — the cache — is honestly modelled by a factory function (withCache()) that captures the cache in a closure; you pick when a fresh cache is created. The composition order in the compose(...) call reads exactly like the Decorator stack.
Common pitfalls
- `compose` vs `pipe` direction.
compose(f, g, h)(x) === f(g(h(x)))(right-to-left, math convention).pipe(f, g, h)(x) === h(g(f(x)))(left-to-right). Pick one convention per project — fp-ts, Effect, Ramda all use right-to-leftcompose. Mixing both in one file invites bugs. - Wrapper state is per-instance. If
withCacheis a top-level constantWrap, every call site shares the same cache. Use a factory (withCache()returning aWrap) when each composition needs its own state — the closure captures the state, the factory hands out fresh ones. - Async wrappers must `await`. Forgetting
await handler(req)inside an async wrapper returns the unresolved Promise upstream — the wrapper around it sees a Promise<Promise<Response>>.try/finallyfor logging needs an actualawaitor you log before the wrapped handler resolves. - Throwing in a wrapper crosses the abstraction. If
withAuththrows, every wrapper outside it must understand that exception. The same is true for the class form, but composition makes it visible — the outer wrappers are obviously the catch sites.
Performance trade-offs
- Time: A composed chain calls n functions; a Decorator-class chain calls n methods. Modern V8 inlines both equally well. No measurable difference at the per-call level.
- Allocations: The composed chain allocates one closure per
composestep at composition time (once, at boot). The class chain allocates one class instance per step. Closures are typically lighter than class instances with a prototype chain — small win, not the reason to do this. - The real cost is in the boilerplate, which the composed form deletes. Three wrapper classes ≈ 60 lines; three wrapper functions +
compose≈ 30 lines.
When NOT to apply (keep the Decorator class)
- Per-instance state with lifecycle — the wrapper holds a database connection, a metrics emitter, or a resource that must be
Symbol.disposed when the wrapper goes out of scope. Classes (especially withusingandSymbol.dispose) model this honestly; closures don't have a destructor hook. - Runtime add/remove of wrappers — users toggle middlewares in a config UI; you need to introspect and reorder the stack at runtime. Each Decorator class is a named addressable thing; closures in a composed chain are opaque.
- Type-narrowing wrappers —
withAuth: (h: Handler) => Handler<AuthenticatedRequest>where the wrapped handler now sees a narrower input type. Function composition can express this with conditional types and overloads, but the class form with a chain of named interfaces is often clearer. (Effect and fp-ts handle this elegantly if you're already in their ecosystem.) - Cross-cutting state shared across all wrappers — a unit of work, a transaction handle, a request-scoped context. A context object passed through
pipeor a stateful class hierarchy both work; pick whichever the team reads more readily.
Related
- GoF class form: `structural-decorator`
- Linear data pipelines (different shape, same machinery): `pipe-pipeline-over-chain-of-responsibility`
- HOF as the building block of any wrapper: `hof-lambda-as-strategy`
Reference: Mostly Adequate Guide — Ch. 5 "Coding by Composing" · MDN — `Array.prototype.reduceRight`
Compose a request pipeline as pipe(handler, handler, handler) instead of linked Handler classes
A model trained on the Chain of Responsibility pattern will define an abstract Handler class with setNext() and handle(), then chain three or four subclasses. In TypeScript — especially in HTTP, validation, parsing, or transformation pipelines — the equivalent is pipe(step1, step2, step3) over an array of small functions. Reach for the class form only when each handler must decide at runtime whether to pass the request along, when handlers carry their own state, or when the chain is reconfigured after construction.
Shapes to recognize
- An
AbstractHandler(orMiddlewarebase class) with anextfield and asetNextmethod - Three to six subclasses, each implementing
handle(request)to do one thing and callsuper.handle(request)orthis.next.handle(request) - A boot-time
auth.setNext(rateLimit).setNext(parse).setNext(route)wiring that never changes - A wired chain whose request type is the same on input and output (no narrowing through the steps)
Incorrect (Chain of Responsibility class hierarchy):
abstract class RequestHandler {
private next?: RequestHandler;
setNext(h: RequestHandler) { this.next = h; return h; }
handle(req: Request): Request {
return this.next ? this.next.handle(req) : req;
}
}
class AuthHandler extends RequestHandler {
handle(req: Request) {
if (!req.headers.authorization) throw new Error('unauthenticated');
return super.handle({ ...req, userId: decodeToken(req.headers.authorization) });
}
}
class RateLimitHandler extends RequestHandler {
handle(req: Request) {
if (!withinRateLimit(req.userId)) throw new Error('rate-limited');
return super.handle(req);
}
}
class ParseBodyHandler extends RequestHandler {
handle(req: Request) {
return super.handle({ ...req, body: JSON.parse(req.rawBody) });
}
}
const chain = new AuthHandler();
chain.setNext(new RateLimitHandler()).setNext(new ParseBodyHandler());
const result = chain.handle(rawRequest);Correct (pipe of pure functions):
type Handler<T> = (req: T) => T;
const authenticate: Handler<Request> = (req) => {
if (!req.headers.authorization) throw new Error('unauthenticated');
return { ...req, userId: decodeToken(req.headers.authorization) };
};
const enforceRateLimit: Handler<Request> = (req) => {
if (!withinRateLimit(req.userId)) throw new Error('rate-limited');
return req;
};
const parseBody: Handler<Request> = (req) => ({ ...req, body: JSON.parse(req.rawBody) });
const pipe = <T>(...fns: Handler<T>[]): Handler<T> =>
(input) => fns.reduce((acc, fn) => fn(acc), input);
const handle = pipe(authenticate, enforceRateLimit, parseBody);
const result = handle(rawRequest);Each step is independently testable as a pure function. Reordering is editing a pipe argument list, not rewiring object graph. The pipe helper is six lines; many projects already have one in fp-ts, Effect, lodash/fp, or remeda.
Common pitfalls
- `pipe` cannot short-circuit cleanly. Throwing inside a step works but uses exceptions for control flow. The functional answer is a
Result<T, E>/Eithertype carried through the pipe — every step pattern-matches on it. Libraries (Effect, fp-ts, neverthrow) provide this; rolling your own for one project is rarely worth it. - Async steps need an async pipe. A
reduceoverPromise<T>doesn't await; you'll threadPromise<Request>instead ofRequest. Either useasync function pipethatawaits, or rely on a library'spipethat knows about promises. The same lambda that works sync may silently break the chain when youasyncit. - Type narrowing across steps is hard to express with raw `reduce`.
pipe(authenticate, ...)whereauthenticate: Request → AuthenticatedRequestcan't propagate the narrowed type through plainreduce. Use overloads (one per arity) or librarypipehelpers that already do this. - Don't reach for `pipe` for two steps.
parseBody(authenticate(req))is clear. Composition pays off at three or more steps.
Performance trade-offs
- Time: identical to chained method calls — n function invocations either way.
- Memory: one closure per
pipestep at composition time; class form allocates one instance per step. Comparable. - The cost is in the libraries. Importing Effect or fp-ts for one
pipeis overkill (~tens of KB); a six-linepipehelper is free.
When NOT to apply (keep the chain)
- A handler must decide at runtime whether to pass the request along or short-circuit and return early —
pipealways runs every step (though early-throw still works,Result/Eithertypes handle this functionally; see Effect/fp-ts) - Handlers carry per-instance state (request counters, connection pools, retry budgets) that must outlive a single request
- The chain is reconfigured at runtime — plugins inserted/removed, middleware order swapped by feature flag — and you need a named handle to each link
- Each step narrows the type of the request (
Request→AuthenticatedRequest→ParsedRequest) and you want the type system to track that progression rigorously — a typedpipe(Effect, fp-ts) handles this, but rawreducedoes not
Related
- GoF class form: `behavioral-chain-of-responsibility`
- Function composition with type narrowing: `pipe-compose-over-decorator` (planned)
Reference: MDN — `Array.prototype.reduce`
Place pure transformer lambdas at module scope, not inside a component or hook
A pure function — one that depends only on its arguments — gets defined fresh on every render when it lives inside a TSX component body. A new function identity each render is invisible to your eye but visible to React's === checks: it defeats memo on children that receive it as a prop, fires useEffect whose deps include it, and forces the React Compiler to think harder about whether anything actually changed. The fix is mechanical: if the lambda captures no component-local state or prop, hoist it to module scope. There it is created once at module evaluation, has a stable reference forever, is automatically tree-shakable, and can be imported by tests directly.
Shapes to recognize
- A
const toUpper = (s: string) => s.toUpperCase()defined inside a component body — captures nothing - A formatter, parser, validator, or key-extractor defined inside a hook — captures nothing from the hook's closure
- A
useMemooruseCallbackwrapping a function that uses no state, no props, no refs — the memo itself is the smell; deletion is the fix, not stabilization - A child
memo'd component that re-renders every parent render, with a callback prop pointing to an inline arrow that calls a pure module-scope function (() => formatPrice(amount)) — the wrapping arrow is the only unstable thing
Incorrect (pure transformer defined inside the component):
import { memo, useEffect } from 'react';
function InvoiceRow({ amount, currency, onSelect }: Props) {
const formatPrice = (n: number) =>
new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(n);
useEffect(() => {
analytics.track('row-viewed', { display: formatPrice(amount) });
}, [amount, formatPrice]);
return <button onClick={onSelect}>{formatPrice(amount)}</button>;
}The formatPrice reference is new on every render, so the useEffect fires on every render, not just when amount changes. Note the dependency is real: formatPrice closes over currency.
Correct (split: module-scope helper plus a thin per-render binding when capture is required):
import { memo, useEffect, useCallback } from 'react';
const formatPrice = (amount: number, currency: string) =>
new Intl.NumberFormat('en-GB', { style: 'currency', currency }).format(amount);
function InvoiceRow({ amount, currency, onSelect }: Props) {
useEffect(() => {
analytics.track('row-viewed', { display: formatPrice(amount, currency) });
}, [amount, currency]);
return <button onClick={onSelect}>{formatPrice(amount, currency)}</button>;
}formatPrice is now a pure two-argument function at module scope: identity-stable, importable, testable in isolation, and not a dependency of the effect. The effect fires only when the data actually changes.
Common pitfalls
- Inline arrows inside `.map(...)` in JSX rendering a list.
arr.map(item => <Row onClick={() => handle(item.id)} />)allocatesarr.lengthclosures per parent render. For a list of 1000 rows re-rendering on parent state change, that's 1000 closures per render. Either rely on React Compiler v1.0 (which auto-memoizes), or defineRowto takeitemIdand passhandleas a stable prop:<Row itemId={item.id} onClick={handle} />withRowcallingonClick(itemId)internally. - `useCallback` on a function that doesn't capture anything.
const fmt = useCallback((n: number) => n.toFixed(2), [])— empty deps array means the function never changes, butuseCallbackstill allocates a Memo cell each render. The honest fix is module-scope:const fmt = (n: number) => n.toFixed(2)at the top of the file.useCallbackis for captured closures whose identity you need stable, not for hand-holding pure functions. - `useMemo` returning a function.
const fn = useMemo(() => () => doThing(x), [x])is a roundaboutuseCallback. UseuseCallback(() => doThing(x), [x])directly — same meaning, less noise. - Module scope vs hook scope for "config-like" helpers. If the helper depends on a runtime config that's the same for the whole app (theme, locale, feature flags loaded at boot), module scope is still right. If the config genuinely varies per render (per-user, per-route), capture it in the hook or pass it as an argument.
Performance trade-offs
- Identity stability is binary. Either the consumer (
memo,useEffectdeps,useMemodeps) skips re-run or it doesn't. A pure transformer at module scope skips correctly; the same transformer in component body never does. - Allocation per render: one closure per inline lambda per component render. For a TSX file with 5 inline lambdas in 100 rendered rows, that's 500 closures per parent render. The cost per closure is tiny (~50 bytes), but the GC pressure compounds; in dev mode (with React strict-mode double-renders), it doubles.
- React Compiler v1.0 changes the rules. With the compiler enabled, most inline lambdas in component bodies are auto-memoized. The "module-scope pure transformers" rule still applies because (a) tree-shaking, (b) testability, (c) covers projects without the compiler, but the urgency drops in compiler-enabled code.
- Tree-shakability: module-scope pure functions used only by one component get tree-shaken if that component isn't imported. Nested-in-component lambdas don't have that property — they're inside the component's closure.
When NOT to apply (keep it nested)
- The lambda does capture something from the component's render (a state value, a prop, a ref's current, a hook return) — hoisting breaks correctness. Pass the captured value as an argument and the rest of the rule still applies
- The transformer is genuinely one-of-a-kind to this component and would never be reused or tested separately — module-scoping it is fine but not mandatory; pick the placement that matches the lifetime of the concept, not just the mechanical stability win
- The function is inside a generator/iterator/closure factory that intentionally produces a new function per call — that's the whole point of the factory; module scope would defeat the design
Related
- React skill rules for the consumer side: `memo-use-callback`, `memo-react-memo`
- Placement of capturing lambdas (the inverse case) is a planned sibling rule
Reference: MDN — Closures: Creating closures in loops, common mistake
Wire many-to-many or one-to-many communication with an event emitter or signal, not a Mediator or Observer class
Two GoF patterns — Mediator (a central hub brokering many-to-many communication between colleagues) and Observer (one publisher notifying many subscribers) — collapse to the same TypeScript shape: an event emitter (or, in reactive systems, a signal / observable). The class-based forms (class Mediator { register(c) {} notify() {} }, class Subject { observers; attach() {} notify() {} }) exist because Java/C# don't have first-class functions ergonomic enough to pass as listeners. TS does — bus.on('user-changed', fn) is one line per subscription. Reach for the class form only when the topology has typed roles you want the compiler to enforce, when the publisher/subscriber needs lifecycle hooks beyond subscribe/unsubscribe, or when you're integrating with a framework whose conventions are class-based.
Pick the right tool for the topology
| Topology | Reach for |
|---|---|
| One publisher, many subscribers, untyped events | EventEmitter / mitt / DOM EventTarget |
| One publisher, many subscribers, typed events | Typed EventEmitter (e.g., mitt<Events>() with TS generics) |
| Reactive state (UI re-renders when state changes) | Signal (Solid, Preact, Vue 3 ref, MobX), React useState, Zustand |
| Stream of values over time with operators | RxJS Observable |
| Many objects all need to consult a shared rulebook | Plain shared module-scope state + functions |
| Cross-component event bus inside a React tree | React Context + useReducer, or a state library (Zustand, Jotai, Redux Toolkit) |
Shapes to recognize
- A
class Subject { private observers: Observer[] = []; attach(o) {} detach(o) {} notify() {} }and Nclass XObserver implements Observer - A
class Mediator(orFormMediator,ChatroomMediator) holding references to every "colleague" and routing messages between them - A method
notifyColleagues()that loops over a list of registered objects and callsupdate()on each - React state that's "lifted" multiple levels and prop-drilled — usually fixable with a signal or context, not a Mediator
Incorrect (Observer class hierarchy for state-change notifications):
interface UserObserver {
update(user: User): void;
}
class UserSubject {
private observers: UserObserver[] = [];
attach(o: UserObserver) { this.observers.push(o); }
detach(o: UserObserver) { this.observers = this.observers.filter((x) => x !== o); }
notify(user: User) { for (const o of this.observers) o.update(user); }
}
class HeaderObserver implements UserObserver {
update(user: User) { document.querySelector('#hdr')!.textContent = user.name; }
}
class SidebarObserver implements UserObserver {
update(user: User) { document.querySelector('#side')!.textContent = `${user.points}pt`; }
}
const userSubject = new UserSubject();
userSubject.attach(new HeaderObserver());
userSubject.attach(new SidebarObserver());
userSubject.notify(updatedUser);Correct (typed event emitter):
import mitt from 'mitt';
type Events = { 'user-changed': User };
const bus = mitt<Events>();
bus.on('user-changed', (user) => { document.querySelector('#hdr')!.textContent = user.name; });
bus.on('user-changed', (user) => { document.querySelector('#side')!.textContent = `${user.points}pt`; });
bus.emit('user-changed', updatedUser);Same semantics, no classes, types enforced on the event name and payload. Each subscriber is a closure — no class XObserver per UI region.
For reactive UI, prefer signals or framework state:
// With Preact signals
import { signal, effect } from '@preact/signals-core';
const currentUser = signal<User>(initialUser);
effect(() => { document.querySelector('#hdr')!.textContent = currentUser.value.name; });
effect(() => { document.querySelector('#side')!.textContent = `${currentUser.value.points}pt`; });
currentUser.value = updatedUser; // both effects fire automaticallyThe signal IS the publisher; effects ARE the observers. The "subscribe" relationship is inferred from which .value reads happen inside the effect closure.
Mediator (many-to-many) shape with an event bus:
type FormEvents = {
'field:changed': { field: string; value: unknown };
'form:submit': { values: Record<string, unknown> };
'form:reset': void;
};
const formBus = mitt<FormEvents>();
// Field component reacts to other fields' changes:
formBus.on('field:changed', ({ field, value }) => {
if (field === 'country') {
formBus.emit('field:changed', { field: 'currency', value: defaultCurrency(value as string) });
}
});
// Submit button:
formBus.on('form:submit', async ({ values }) => { await api.save(values); });The bus is the mediator. No class FormMediator { onCountryChanged() … onCurrencyChanged() … } with N methods coupling every pairwise interaction.
Common pitfalls
- Memory leaks from unbound subscriptions.
bus.on('foo', handler)keepshandler(and everything it closes over) alive untilbus.off('foo', handler). In React, alwaysuseEffect(() => { bus.on(...); return () => bus.off(...) }, []). In long-lived bridges, document the subscribe/unsubscribe contract. - Untyped events.
emitter.emit('user-chanded', user)(typo) silently never fires the listener. Use a typed emitter (mitt<Events>()) or aas constkeyed registry to make typos compile errors. - Synchronous fan-out blocking the publisher.
bus.emit('x')runs all subscribers synchronously in the publisher's stack. A slow subscriber blocks the next. For "fire-and-forget" semantics, wrap subscribers inqueueMicrotask(() => handler(data))or use async event buses. - Subscribers running in undefined order. Most emitters guarantee insertion order; some don't. If your subscribers depend on each other's effects, they're not properly independent — restructure.
- Cross-process / cross-tab events.
bus.emitin one tab doesn't reach another. UseBroadcastChannel(native),localStorageevents, or a server-side bus. - Mediator that grew into a god-object. A
FormMediatorthat handles every possible field-pair interaction becomes unmaintainable. Either split into multiple buses by domain, or move toward derived state (signals: each derived field iscomputed(() => fn(deps))).
Performance trade-offs
- Time:
bus.emit(...)is O(subscribers). For ~10 subscribers, microseconds. For thousands (highly fanned-out app state in a non-reactive system), measurable — at which point signals/observables (which build dependency graphs and only fire on actual reads) are more efficient. - Memory: each subscription is a closure + an entry in the emitter's internal list. Roughly equivalent to an object reference per listener.
- Reactive signals are typically faster than event buses for UI state because the signal library tracks reads — only the effects that actually use a value run when it changes. An event bus broadcasts to every listener regardless of whether the change affected them.
- No fan-out cost for unused signals. A
signal()with zero subscribers does no work on write. Abus.emit()with one subscriber that doesn't care still calls the subscriber.
When NOT to apply (keep Mediator / Observer class)
- Typed roles with compiler-enforced contracts. When the system has a fixed set of colleagues (
Pilot,Tower,GroundCrew) with specific message types each can send/receive, a typed class-based Mediator can make role mismatches compile errors. A generic event bus can do this with discriminated event types, but the class form is sometimes clearer - Framework expects classes. Some component frameworks (Angular services, NestJS event emitters with decorators, RxJS subjects in class-based services) integrate naturally with class subjects. Match the surrounding style
- The publisher carries domain state. A
Subjectthat is itself a domain object (aStockthat notifies of price changes) and not just a pub-sub conduit may earn a class. Even then — the class can use an internal emitter; the public surface is what counts
Related
- GoF class forms collapsed: `behavioral-mediator`, `behavioral-observer`
- For tagged state transitions inside one component (State, not Observer): `match-tagged-union-over-state-visitor-composite`
- For closures stored in a queue (Command, not Observer): `closure-as-command`
Reference: MDN — `EventTarget` · Preact Signals — docs · `mitt` — tiny typed event emitter
Use flatMap for one-to-many transforms instead of nested loops or map().reduce(concat)
A model trained on imperative loops writes nested for blocks with a mutated accumulator array when "for each user, expand to all their orders, then collect everything." The result is hard to follow because the iteration mechanics dominate the data transform. Array.prototype.flatMap says exactly one thing — "for each input, produce zero or more outputs, then concatenate" — and removes the accumulator entirely. The shape "map then flatten by one level" is so common that flatMap exists as a single call. Avoid map().flat() (two passes, two allocations) unless you specifically need the depth-N variant via flat(N).
Shapes to recognize
const out: T[] = []followed byfor ... { for ... { out.push(...) } }where the outer iteration is per-parent and the inner is per-child.map(x => f(x)).reduce((acc, xs) => acc.concat(xs), [])— the manual fold of map's array-of-arrays output.map(x => f(x)).flat()— equivalent toflatMapbut two passes; reach forflat(N)only when you need depth > 1
Incorrect (nested loop with mutated accumulator):
function allOrderLineItems(users: User[]): LineItem[] {
const items: LineItem[] = [];
for (const user of users) {
for (const order of user.orders) {
for (const item of order.lineItems) {
items.push(item);
}
}
}
return items;
}Correct (chained flatMap):
function allOrderLineItems(users: User[]): LineItem[] {
return users.flatMap((user) => user.orders.flatMap((order) => order.lineItems));
}The chained form reads top-down as "users → their orders → their line items" — the same way the type names compose. The imperative version forces the reader to track items.push across three nesting levels and confirm nothing else mutates the accumulator.
Common pitfalls
- `flatMap` with `async` returns `Promise[]`, not `Promise<flat[]>`.
arr.flatMap(async x => fetch(x))produces an array of promises that flattens by one level into… still an array of promises. UsePromise.all(arr.map(...))for parallel awaiting, orfor await…offor sequential. The model frequently writes this anti-pattern when porting imperative async loops. - `map().flat()` is two passes. Equivalent to
flatMaponly if you don't needflat(N)for depth > 1. Default toflatMap; reach forflat(N)only when the nesting depth is the explicit thing you're collapsing. - `flatMap` doesn't preserve indices. If the outer-index of the originating row matters downstream, capture it inside the mapping (
users.flatMap((u, i) => u.orders.map(o => ({ ...o, userIndex: i })))) —flatMapitself doesn't expose the path. - Empty arrays are silent.
users.flatMap(u => u.orders)over a user with no orders just skips that user; you lose the row. Use.map(u => ({ user: u, orders: u.orders }))if you want to preserve the parent.
Performance trade-offs
- Time:
flatMapis O(n + total-output) — the inner walks dominate. Tight nestedfor-ofloops withpushare typically 1.5–3× faster on V8 for large inputs (10k+ rows), mostly due to closure allocation and intermediate-array setup. - Allocations:
flatMapallocates one intermediate array per level. Three-deepflatMapallocates 3 arrays; the nested loop allocates 1. - Readability vs throughput trade-off: the chained
flatMapform costs 1.5–3× CPU and proportional GC. For a per-render UI transform of a few hundred items, this is unmeasurable. For a server hot path over 100k+ items, the loop wins. Profile, don't guess. - For huge inputs: consider switching to iterator helpers (
Iterator.from(arr).flatMap(...)) which avoids intermediate arrays — see `stream-lazy-iteration-for-large-or-infinite`.
When NOT to apply (keep the loop)
- The body has meaningful side effects that benefit from early-
break(writing to a stream, awaiting one network call at a time, stopping on first match) —for...ofplusbreakis correct;flatMapdoesn't short-circuit - Performance matters and the inputs are very large — see Performance trade-offs above
- The transform is asynchronous and order-sensitive — use
for...ofwithawait, notflatMapof promises (which givesPromise[], notPromise<flat[]>)
Related
- GoF class form: `behavioral-iterator` — a custom Iterator class to walk a tree is rarely needed when
flatMapcovers the same job - Aggregation cousin:
reducefor fold-to-single-value (counts, sums, groups)
Reference: MDN — `Array.prototype.flatMap`
Use generators or Iterator helpers for early-exit over large or infinite sequences
A model trained on eager array methods writes arr.filter(p).slice(0, 10) for "give me the first ten matches." On a 10-million-row array, that materializes every match before throwing all but ten away — O(n) time guaranteed, O(matched-total) memory. The functional answer is lazy iteration: a generator or a TC39 Iterator helper chain that produces values on demand and stops the moment the consumer is satisfied. Time becomes O(matched-needed), memory becomes O(1) per step plus the small output buffer. For finite small arrays the eager form is fine and often faster (one tight loop beats per-step iterator protocol overhead); the rule fires when the input is large, expensive to produce, or unbounded.
Shapes to recognize
.filter(...).slice(0, N)or.filter(...).find(...)over arrays with > ~10⁴ items- Reading lines from a large file, JSON-streaming an API response, or paginating a backend — each "page" is expensive
- Any loop where the input is conceptually infinite (random sampling until convergence, retry-with-backoff, polling)
- Manually keeping a counter inside a
for-ofto bail out at N matches — the imperative shape of a take(N)
Incorrect (eager: materialize all matches, throw most away):
function firstTenOverdue(invoices: Invoice[]): Invoice[] {
return invoices.filter((i) => i.dueDate < new Date()).slice(0, 10);
// Walks every invoice; allocates one Invoice[] sized = all overdue; returns 10.
}Correct (lazy: stop after the tenth match):
function firstTenOverdue(invoices: Iterable<Invoice>): Invoice[] {
const now = new Date();
return Iterator.from(invoices)
.filter((i) => i.dueDate < now)
.take(10)
.toArray();
// Iterator helpers (TC39 Stage 4, Node 22+ / TS 5.6+): stops after the 10th match.
}For older targets without iterator helpers, a generator + a take helper:
function* overdue(invoices: Iterable<Invoice>): Generator<Invoice> {
const now = new Date();
for (const i of invoices) if (i.dueDate < now) yield i;
}
function take<T>(iter: Iterable<T>, n: number): T[] {
const out: T[] = [];
for (const x of iter) {
if (out.length >= n) break;
out.push(x);
}
return out;
}
const firstTen = take(overdue(invoices), 10);The reader sees the early-exit and the laziness explicitly. The for-of driver loop is just a manual implementation of what Iterator.prototype.take does built-in.
Common pitfalls
- `Array.from(infiniteIterable)` materializes forever.
Array.from(naturals())never returns. Only call.toArray()/Array.from/ spread on bounded iterators — guard with.take(n)upstream. - Array methods on an array are eager regardless.
[...largeArr].filter(...).take(...)does NOT save work —filteron an Array still walks the whole array beforetakeruns. You must move into iterator-land withIterator.from(arr)or a generator function for laziness to kick in. - Generators close on early return. Code in a
try/finallyblock inside a generator runs when the consumer stops iterating (e.g.,breaks out,takefinishes). Use this for cleanup of file handles, network streams, DB cursors. - *`async function
is separate.** Async iterators (for await…of`) are how you do this over streaming I/O. Don't mix sync and async iterator protocols silently.
Performance trade-offs
- Time: O(matched-needed) vs O(n) when bailing early. For "first 10 of 10M" with a 1% match rate, that's reading ~1000 items vs all 10M — a 10⁴× saving.
- Memory: O(1) per-step (the iterator's internal state) plus the output buffer, vs O(matched-total) for eager. For a stream of 1KB records and 100K matches, that's 100MB held in flight vs <1MB.
- Constant-factor cost: Iterator protocol overhead (per-step method calls) is real. For small arrays (< a few hundred items) or chains that always consume everything, eager array methods are faster. The rule fires only when the upstream is large, expensive, or unbounded.
- Native iterator helpers vs library helpers: built-in
Iterator.prototype.*(TC39 Stage 4) avoids the overhead of a library wrapper class. Prefer native when target supports it.
When NOT to apply (keep the eager array form)
- Small finite arrays (< ~1000 items) where the entire result is consumed — eager is simpler and often faster
- The chain has no early-exit and consumes every element anyway — laziness gains nothing, only protocol overhead
- You need array-only methods (
.reverse,.sort, indexed access,.length) — iterators don't expose those; materialize first - Working in code that targets Node < 22 / TS < 5.6 AND you don't want to write the generator+take boilerplate — the eager form is fine for small data
Related
- Adjacent stream rule for transforming finite collections: `stream-flatmap-over-nested-loops`
- Avoiding the multi-pass cost when laziness isn't enough: `stream-prefer-single-pass-over-chained-passes`
- GoF class form: `behavioral-iterator` — Iterator-as-a-class is the eager imperative ancestor of these generators
Reference: TC39 — Iterator Helpers proposal · MDN — Iterator protocol
Collapse .filter().map().filter() chains into a single pass when the input is large or the chain is hot
Each call in arr.filter(p).map(f).filter(q) is its own complete walk plus an intermediate array. Three chained Array methods on a 100k-row array do three full traversals and allocate two intermediate arrays (the output of .filter(p) and the output of .map(f)) before producing the final one. For small data this is invisible; for large data or a hot path, the constant factor adds up to 2–5× over an equivalent single pass via reduce or for-of, and peak memory roughly doubles because the intermediate arrays exist simultaneously while the next stage builds. The fix is not "never chain" — it's "chain when the chain is intent-revealing and short, collapse when the chain is in a hot path or the data is large."
Shapes to recognize
- Three or more
.filter/.map/.flatMapcalls in a row over a non-tiny array - A chain ending in
.lengthto count matches — every intermediate array is built and discarded - A chain inside a render path, a request handler, or an event loop tick — fires on every interaction
- A chain that begins with
.filter(p)whose predicate is cheap but the array is huge — half the work is producing an intermediate of ~half the size, only to throw it away after one more pass - A
.filter(...).map(...)that could be a.flatMap(x => p(x) ? [f(x)] : [])or, better, areduce
Incorrect (three passes + two intermediate arrays):
function activeUserEmails(users: User[]): string[] {
return users
.filter((u) => u.status === 'active') // pass 1, allocates User[]
.map((u) => ({ ...u, email: u.email.trim() })) // pass 2, allocates {...User}[]
.filter((u) => u.email.endsWith('@acme.com')) // pass 3, allocates User[]
.map((u) => u.email); // pass 4, allocates string[]
}Four passes, four allocations of size proportional to surviving input.
Correct (single pass, single output array):
function activeUserEmails(users: User[]): string[] {
return users.reduce<string[]>((out, u) => {
if (u.status !== 'active') return out;
const email = u.email.trim();
if (!email.endsWith('@acme.com')) return out;
out.push(email);
return out;
}, []);
}One pass, one allocation (the output, sized to actual matches, not generously-sized intermediates). The mutated accumulator is fine here — it's the documented "fold with push" shape, and the alternative (acc.concat(email)) is O(n²).
Equivalent with a generator if you also want laziness:
function* activeUserEmails(users: Iterable<User>): Generator<string> {
for (const u of users) {
if (u.status !== 'active') continue;
const email = u.email.trim();
if (email.endsWith('@acme.com')) yield email;
}
}Common pitfalls
- The order of `.filter` and `.map` matters for cost. Always filter before map when possible — mapping then filtering does work on rows you're about to discard.
arr.map(expensive).filter(p)is strictly worse thanarr.filter(p).map(expensive)wheneverpdoesn't depend onexpensive's output. - `.length` after a chain.
arr.filter(p).lengthis a chain that allocates an intermediate array just to count it. Usearr.reduce((n, x) => p(x) ? n + 1 : n, 0)or afor-ofcounter. - Premature collapse hurts readability. A two-step
.filter(active).map(name)on a small list is clearer chained than reduced. The rule fires on chains of three or more stages over large or hot data — not on every two-step chain. - Don't reach for `reduce` if the chain is already a `flatMap`.
flatMapis one pass at this level; chaining.filter().flatMap()is two, but combining into aflatMap(x => p(x) ? [f(x)] : [])is one and idiomatic.
Performance trade-offs
- Time: chained Array methods are O(k·n) where k is the chain length, single-pass is O(n). The constant factor (function-call overhead per step) means for small n the chained form may even win — measure if it matters.
- Memory peak: chained is O(n_after_filter1 + n_after_map + n_after_filter2 + …) held simultaneously while the next pass runs. Single-pass is O(output) only. For a 100MB array filtering down to 10MB, chained peaks at ~200MB+; single-pass peaks at ~110MB.
- GC pressure: each intermediate array is short-lived garbage. In server hot paths under load, that's per-request allocation churn that costs latency tail percentiles.
- Native iterator helpers (TC39 Stage 4) —
Iterator.from(arr).filter(p).map(f).filter(q).toArray()— produce one output array regardless of chain length, because each helper is lazy and pulls one item at a time. For arrays this is the prettiest single-pass form that retains the chained look.
When NOT to apply (keep the chain)
- Small arrays (< a few hundred items) — readability dominates; the perf difference is unmeasurable
- Cold code paths — a startup config transform, a one-off script — readability wins
- The chain is the documentation —
.filter(active).filter(verified).filter(notArchived)reads as a clear three-way conjunction; collapsing it into a reduce with three nestedifs is worse - You're already using iterator helpers —
Iterator.from(arr).filter(...).map(...)...toArray()is already a single pass; further collapsing buys nothing
Related
- The fold tool used in the single-pass form: `stream-reduce-over-imperative-accumulation`
- For early-exit cases (the chain ends in
.slice(0, n)or.find): `stream-lazy-iteration-for-large-or-infinite` - One-to-many in one step: `stream-flatmap-over-nested-loops`
Reference: MDN — `Array.prototype.reduce` · V8 blog — Array iteration performance
Related skills
FAQ
What does implementation-functional-patterns do?
implementation-functional-patterns is a Claude Code skill in the AI & Agent Building category.
When should I use implementation-functional-patterns?
When you need to helps with ai & agent building tasks during ai-assisted development, or when implementation-functional-patterns is a claude code skill in the ai & agent building category.
What are the main capabilities?
implementation-functional-patterns; AI & Agent Building; AI-coding skill.