Definition
A composite arranges objects into a tree that represents a part–whole hierarchy, so clients can treat a single object and a group of objects the same way.
After Gamma et al., Design Patterns (1994), Composite
01Start with one valve
A zone is a list of valves, until it holds a zone.
A controller can pause one valve with a direct call. When a leak affects a whole zone, a list of valves and a loop is still a reasonable solution:
// West is a list of valves; pausing it is a loop.
type Valve = { id: string; paused: boolean };
function pauseAll(valves: Valve[]): void {
for (const valve of valves) valve.paused = true;
}
const west: Valve[] = [
{ id: 'east-valve', paused: false },
{ id: 'propagation-valve', paused: false },
];
pauseAll(west); It says exactly what happens, and while a zone is a flat list there is nothing to improve. The pressure changes when zones contain smaller zones. The nursery gets its own schedule inside the west zone, the house holds both, and now pausing west means pausing its valves and the nursery’s. Callers start learning the structure and repeating traversal rules: the pause command, the resume command and the status screen each need a loop per level, and each one breaks when someone nests a level deeper.
Composite gives an individual part and a group of parts a common operation. A valve handles the command itself. A zone sends the command to its direct children, each of which may be another zone.
Here the operation is command("pause") or command("resume"). Pause
changes the valve state; the trace makes call order visible. The example assumes an in-memory
controller and prepared, acyclic zone data. You already use groups like this:
| The group | What it holds | Why callers need not care |
|---|---|---|
| AbortSignal.any([a, b]) | Signals | It is an AbortSignal: fetch takes it like any other. |
| A JSON value | Values, arrays and objects of values | JSON.stringify writes a number or a nested object the same way. |
| A test suite (describe) | Tests and suites | Skipping a suite skips everything in it. |
02One command, delegated in order
Valves and zones share one command.
House 1 contains the North valve and the West zone. West contains the East valve and Nursery. Nursery contains the Propagation valve. A bench valve by the potting bench belongs to no zone. This is the whole program the lab runs: the shared command, the valve, the zone, a controller that sends one command to whatever the operator selected, and two sends. A group handles its own call, then delegates once to each immediate child.
type Action = 'pause' | 'resume';
// What a valve and a zone have in common: one command.
interface IrrigationPart {
readonly id: string;
command(action: Action, trace: string[]): void;
}
class Valve implements IrrigationPart {
paused = false;
constructor(readonly id: string) {}
command(action: Action, trace: string[]): void {
trace.push(this.id);
this.paused = action === 'pause';
}
}
class Zone implements IrrigationPart {
constructor(readonly id: string, readonly children: IrrigationPart[]) {}
command(action: Action, trace: string[]): void {
trace.push(this.id);
for (const child of this.children) child.command(action, trace);
}
}
// The controller: one command to whatever the operator selected.
function send(part: IrrigationPart, action: Action): string[] {
const trace: string[] = [];
part.command(action, trace);
return trace;
}
const nursery = new Zone('nursery', [new Valve('propagation-valve')]);
const west = new Zone('west', [new Valve('east-valve'), nursery]);
const house = new Zone('house-1', [new Valve('north-valve'), west]);
const bench = new Valve('bench-valve'); // the potting bench, in no zone
// A leak report pauses the whole house; maintenance pauses one valve.
const leak = send(house, 'pause');
const maintenance = send(bench, 'pause');
console.log(`leak: ${leak.join(' > ')}`);
console.log(`maintenance: ${maintenance.join(' > ')}`); | Relationship | What it does |
|---|---|
| Controller → IrrigationPart | Issues one command to the part the operator selected. |
| Zone → children | Records its own visit, then delegates once to each child, in order. |
| Valve → state | Applies pause or resume to itself. |
The controller does not know whether it was handed a valve or a house: it calls command once and returns the trace. Sending pause to House 1 visits its parts in
preorder, each zone before its children, the children in the order the zone stores them.
03Predict the walk
Who should walk the zone: the controller, or the zone?
Experiment
One command, a valve or a zone
// send (lines 27–31) becomes this. It also walks a zone's
// children itself, to be sure.
function send(part: IrrigationPart, action: Action): string[] {
const trace: string[] = [];
part.command(action, trace);
if (part instanceof Zone) {
for (const child of part.children) {
trace.push(...send(child, action));
}
}
return trace;
} What a production controller addsFailures, validation and a copy of the children
In a production controller, a sensor or operator supplies the selected zone and action. The
controller checks authorization and operating policy, then calls the selected IrrigationPart once. Each zone owns delegation to its immediate children; each
valve owns its local state transition.
The version below rejects an unknown action before anything moves. A valve that does not respond is reported, and its siblings still get the command, so the result says which valves failed. A zone refuses a part already in its tree, since a part listed twice would get every command twice. The zone keeps a frozen copy of its children, so a later change to the caller’s array cannot reorder it.
export type Action = 'pause' | 'resume';
export interface IrrigationPart {
readonly id: string;
/** Applies the action; returns the ids of valves that did not respond. */
command(action: Action, trace: string[]): string[];
/** This part's id and every id below it. */
ids(): string[];
}
export class Valve implements IrrigationPart {
paused = false;
constructor(
readonly id: string,
private readonly stuck = false,
) {}
command(action: Action, trace: string[]): string[] {
trace.push(this.id);
if (this.stuck) return [this.id];
this.paused = action === 'pause';
return [];
}
ids(): string[] {
return [this.id];
}
}
export class Zone implements IrrigationPart {
readonly children: readonly IrrigationPart[];
constructor(
readonly id: string,
children: readonly IrrigationPart[],
) {
// A part listed twice would get every command twice.
const seen = new Set([id]);
for (const childId of children.flatMap((child) => child.ids())) {
if (seen.has(childId)) {
throw new Error(`zone ${id}: duplicate part ${childId}`);
}
seen.add(childId);
}
this.children = Object.freeze([...children]);
}
command(action: Action, trace: string[]): string[] {
trace.push(this.id);
// Every child gets the command, even after one fails.
return this.children.flatMap((child) => child.command(action, trace));
}
ids(): string[] {
return [this.id, ...this.children.flatMap((child) => child.ids())];
}
}
export function send(part: IrrigationPart, action: string) {
if (action !== 'pause' && action !== 'resume') {
throw new Error(`unknown action: ${action}`);
}
const trace: string[] = [];
const failed = part.command(action, trace);
return { trace, failed };
} Composite does not provide transport, retries, transaction boundaries, or hardware safety. A physical controller may need to report partial failure if one valve does not respond, and decide whether to continue to later children. Those are additional command-delivery policies; this version picks “continue, and report”.
Reading the TypeScriptInterfaces, instanceof and readonly children
The IrrigationPart interface gives a valve and a zone the same command method. The zone stores its children through that interface and does
not need to know their concrete classes. implements only asks the compiler to
check the shape; any object with an id and a matching command would do.
The careless controller’s instanceof Zone is the moment a caller starts
knowing the tree’s classes, which is the knowledge the pattern exists to keep in the zone. readonly children stops reassignment of the field, not changes to the array;
the production zone copies and freezes it.
04Meet it in your language
A group you already treat as one value.
The platform hands you a composite: AbortSignal.any() (Node 20.3; Chrome 116,
Firefox 124, Safari 17.4) returns a signal made of signals. It is an AbortSignal like the ones it holds, so fetch, streams and your own
functions take it without knowing it is a group, and a group can hold a group: a user’s
cancel and a timeout, inside one with the server’s shutdown. Before it existed, code combined
signals by hand, and the hand-made group got one case wrong:
// Before: a hand-made group of signals. A signal that has already aborted
// never fires "abort" again, so if the user cancelled before this ran, the
// combined signal never aborts and the request runs anyway.
export function combineSignals(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
signal.addEventListener('abort', () => controller.abort(signal.reason), {
once: true,
});
}
return controller.signal;
}
// After: the platform's group (Node 20.3, every current browser). It is an
// AbortSignal like the ones it holds, aborted already if any of them is, so
// fetch and your own code take it without knowing it is a group, and a
// group can hold a group.
export function requestSignal(user: AbortSignal, shutdown: AbortSignal) {
const withTimeout = AbortSignal.any([user, AbortSignal.timeout(5_000)]);
return AbortSignal.any([withTimeout, shutdown]);
} A group should answer every question its parts answer, including “are you aborted already?”.
The hand-made one only listens for the next abort, so a signal that aborted before the
request started never reaches it; AbortSignal.any starts aborted when any of
its signals is. Keep functions taking a plain AbortSignal, and let callers pass a
group.
05Decide when it helps
Give the controller a change to absorb.
Give the controller a plausible change. Follow which code has to know about it when callers walk the tree themselves, and when each zone owns its part of the walk.
| The change | Callers walk the tree | Composite |
|---|---|---|
| Zones nest one level deeper | Every fixed-depth loop needs another level, in every caller. | Nothing changes: a zone passes the command on, whatever it holds. |
| A new kind of part (a drip line that pauses its emitters) | Every caller that checks kinds has to learn the new one. | One new type with the shared command; zones and the controller stay as they are. |
| A new operation (list the paused valves) | One more walk, written in the caller that needs it. | One more method on every part type: the price of the shared interface. |
| One valve stops responding | Each walking caller decides, perhaps differently. | One place decides, but Composite does not say what: continue, stop, or report. |
Keep a flat list and one loop when the equipment has no nested groups. Composite earns its boundary when callers need to apply the same operation to a part or a nested whole without duplicating traversal; it adds forwarding/group types and recursive depth to maintain.
What the pattern leaves for you to decide:
- Order. Children in stored order is a promise callers will rely on; say so, or say it is unspecified.
- Before or after. This zone records its visit, then delegates (preorder). A zone that closes a main valve only after its children finished would act after them.
- Failure. Whether a failed child stops its siblings, and how a partial result is reported.
- Where children are managed. Adding and removing children on the shared interface makes every part look the same (the book calls it transparency) but gives a valve methods it cannot honour; keeping them on the group is safer and makes callers know which they hold.
- Whether it is a tree. The interface does not stop a part being listed twice, or a zone inside itself.
The rule for what goes where: the shared interface carries the operations a caller sends to any part; the group keeps its children and how it passes an operation on; the leaf keeps its own state.
If zone links stop forming a treeRepeated work, cycles, and depth
The example assumes each valve or zone has one place in a prepared, acyclic tree. If the same mutable valve is linked under two zones, a command sent to their common parent reaches it twice. A cycle would keep delegation from finishing. Validate imported links and impose a depth limit before dispatch when data is not trusted; the shared interface does not enforce those rules.
Its neighbours: Decorator wraps one part and adds behaviour, where a composite holds many and passes the call on; Iterator walks a composite’s parts without exposing how it is built; Visitor adds an operation without adding a method to every part type; and Interpreter uses a nested tree too, with each node evaluating part of an expression.
06Make the call
A tree of parts, or a list and a loop?
Before you open the answer below, say it in your own words: what does one call to a zone delegate, who owns the walk, and how would you know if a child received a command twice?
One way to say itOne call, one owner of the walk
A part and a group share one operation, so the caller sends it once to whatever it was handed. A group handles its own share, then passes the operation once to each child, and each child does the same, so the walk is owned by the tree, one level at a time. If a caller also walks the tree, every zone it calls starts the delegation again, and a part receives the command once for each zone above it plus one: the deeper it sits, the more copies. A trace, or a count of calls per part, shows it. The same move answers questions: a leaf reports its own number and a group adds its children’s, which is how a folder’s cost stays right as the tree changes shape. And when nothing nests, a list and a loop say it more plainly.
Practice in the lab
A company mail server sends to an address or to a distribution list, and lists can hold other lists: all-staff holds engineering, engineering holds platform. Like a zone, a list should take the same send as one address and pass it on to its members. Each level starts from the previous level’s finished code, so you can begin at any of them.
- COMPOSITE-101 (easy) A list inside a list reaches nobody
- COMPOSITE-102 (medium) Dana is on two lists and gets two copies Pro
- COMPOSITE-103 (hard) Review: lists that include lists by name Pro