Composite
The bug report was one line: “grouping a group breaks the selection outline.”
We had a small vector editor. You draw rectangles and circles, drag a marquee around a few of them, hit the group shortcut, and now they move as one. Groups held shapes. The code that drew the dashed outline around a selection assumed exactly that, and so did the code that moved it:
// move the current selection by (dx, dy)
function moveBy(item, dx, dy) {
if (item.kind === "group") {
for (const shape of item.shapes) {
shape.x += dx; // assumes every child is a plain leaf shape
shape.y += dy;
}
} else {
item.x += dx;
item.y += dy;
}
}
// the box we draw the outline around
function bounds(item) {
if (item.kind === "group") {
let box = null;
for (const shape of item.shapes) {
box = union(box, { minX: shape.x, minY: shape.y, maxX: shape.x + shape.w, maxY: shape.y + shape.h });
}
return box;
}
return { minX: item.x, minY: item.y, maxX: item.x + item.w, maxY: item.y + item.h };
}
Read it and nothing looks wrong. It shipped and worked for months. Then a designer selected two existing groups, grouped those, and everything fell over. A group now had a group inside it, so shape.x was undefined, undefined + dx was NaN, and the outline snapped to the top-left corner of the canvas. Move was just as broken, silently.
The honest diagnosis was not “we forgot a case”. It was that the code treated “a shape” and “a group of shapes” as two fundamentally different kinds of thing, and every single operation had to know which one it was holding. Move branched on it. Bounds branched on it. Delete branched on it. Hit-testing branched on it. Four separate places re-deriving “is this one thing or many”, and only some of them happened to survive a group nested in a group.
The check is the smell
Count the ways that code can grow. Add a new operation (align, distribute, export to SVG) and you write another function with the same if (item.kind === "group") fork in it. Add a new leaf type (an image, a text label) and you revisit every one of those forks to teach it the new shape. Operations times node-types, and every cell is a branch someone can forget.
That branch is the tell. Whenever you find yourself asking “is this a single item or a collection of items” right before doing the actual work, and you ask it in more than one place, you have a part-whole hierarchy fighting the code that walks it.
The fix is to stop asking. Give a single shape and a group of shapes the same interface, and let the group implement each operation by delegating to its children. Then a group’s moveBy just calls moveBy on each child, and it does not care in the slightest whether a child is a leaf shape or another group. The recursion you were writing by hand, badly, four times, falls out of the structure for free.
That is the composite pattern. One interface shared by leaves and containers, a container that satisfies the interface by asking its children, and caller code that calls one method on any node without knowing or caring how deep the tree goes underneath.
The refactor
In a Java patterns book this is where you meet an abstract Component class, a Leaf subclass, a Composite subclass, and a diagram of inheritance arrows. You do not need any of that in JavaScript, and reaching for it first is how these lessons end up more complicated than the problem. The interface is a shape, not a base class. Any object with the right methods qualifies.
A leaf is a factory function that closes over its own state and exposes the operations. A rectangle keeps its geometry private in a closure and answers three calls:
const union = (a, b) => a ? {
minX: Math.min(a.minX, b.minX), minY: Math.min(a.minY, b.minY),
maxX: Math.max(a.maxX, b.maxX), maxY: Math.max(a.maxY, b.maxY),
} : b;
function rect(x, y, w, h) {
return {
kind: "rect",
moveBy(dx, dy) { x += dx; y += dy; },
bounds() { return { minX: x, minY: y, maxX: x + w, maxY: y + h }; },
draw(ctx) { ctx.strokeRect(x, y, w, h); },
};
}
A group holds children and satisfies the same three calls by folding over them. This is the whole pattern, and it is one small function:
function group(children = []) {
return {
kind: "group",
children,
add(node) { children.push(node); return this; },
moveBy(dx, dy) {
for (const child of children) child.moveBy(dx, dy);
},
bounds() {
return children.reduce((box, child) => union(box, child.bounds()), null);
},
draw(ctx) {
for (const child of children) child.draw(ctx);
},
};
}
Look at what a group’s moveBy does. It calls child.moveBy. It never checks whether child is a rect or another group, because both answer moveBy. So a group inside a group inside a group just works: the outer group asks the middle one, which asks the inner one, which asks its leaves. The nesting that broke the original code is now the thing the code is built out of.
const logo = group([rect(0, 0, 40, 40), circle(60, 20, 15)]);
const banner = group([logo, text(120, 10, "Sale")]);
banner.moveBy(10, 0); // moves every leaf in the whole nested tree
banner.bounds(); // unions every leaf, however deep it sits
The caller does not branch. The operations do not branch. Add an image leaf and you write one factory with the same three methods and change nothing else. Add an align operation and you add one method to rect and one to group; the group’s version delegates, exactly like the others. The operations-times-types matrix that used to be a field of if statements collapsed into “each node knows how to do the thing, and a container’s answer is to ask its children”.
Recursion is the point, not a side effect
The uniform interface is what callers see. The engine underneath is recursion, and it is easiest to feel with an operation that returns a number. Swap shapes for a file system, which is the composite everyone already carries in their head. A file has a size. A folder’s size is the sum of its entries’ sizes, and some of those entries are folders, whose size is the sum of their entries, all the way down.
const file = (name, bytes) => ({
kind: "file",
name,
size() { return bytes; },
count() { return 1; },
});
const folder = (name, entries = []) => ({
kind: "folder",
name,
add(entry) { entries.push(entry); return this; },
size() { return entries.reduce((sum, e) => sum + e.size(), 0); },
count() { return entries.reduce((sum, e) => sum + e.count(), 0); },
});
const project = folder("project", [
file("README.md", 4_000),
folder("src", [
file("app.js", 20_000),
file("util.js", 8_000),
]),
]);
project.size(); // 32000, summed across two levels
project.count(); // 3
There is no base case written anywhere, no depth parameter, no manual stack. The base case is the leaf: file.size() just returns a number. The recursive case is the folder: folder.size() calls size() on each entry and adds them up. Because a child folder answers size() the same way, the sum reaches the leaves without the caller ever seeing a loop. This is precisely the computation du runs when it reports how much a directory weighs.
You already use this every day
The reason the file-system example needs no explanation is that a file system is a composite, and so is the thing rendering this page. The DOM is the composite you have used since your first querySelector.
Every node in the document, whether it is a <div>, a run of text, or a comment, is a Node. Element and Text and Comment all inherit that one interface, and the tree-shaped operations live on it. parent.appendChild(child) works whether the child is a lone text node or an entire subtree. node.childNodes gives you the children of anything. And node.textContent is a composite read in the purest sense: ask an element for its text and you get the concatenation of the text of it and every descendant, gathered by the same recursion your folder.size() runs. You never write if (node is an element) walk the children else return the string. The Node interface already did that.
// textContent folds the whole subtree into one string. You call it on
// the container; the DOM walks the descendants for you.
document.querySelector("article").textContent;
// cloneNode(true) is a composite operation the platform ships:
// the node knows how to duplicate itself and its entire subtree.
const copy = original.cloneNode(true);
That deep cloneNode(true) is worth pausing on. The true means “clone the subtree too”, and it works because a Node knows how to clone itself and then ask each child to clone in turn. It is folder.size() with cloning instead of summing. The DOM node fundamentals lesson goes deeper on the interface itself; what matters here is that you have been calling composite operations for years without naming them.
Click a folder, watch it recurse
Here is the file-system composite as a live thing. Every row shows its own size() (files return their bytes, folders return the fold over their entries). Click any node and it recomputes size() and count() through the exact interface above, and every descendant that the recursion touched lights up. Click a single file and only that file lights up, count() is one. Click the root and the whole tree flashes.
The awkward part nobody mentions
Here is the honest tension, and every good composite lesson has to say it out loud. For the interface to be truly uniform, a leaf has to implement the container operations too. A folder has add(entry) and remove(entry). Does a file? A file has no children. There is nothing to add to. But if file does not implement add, then the interface is not uniform after all, and code that wants to call add on “some node” has to check which kind it is holding, which is the exact check we set out to delete.
The classic pattern names two ways out, and they genuinely conflict.
The transparent version puts every operation on every node, including add and remove. A file implements add by throwing, or by quietly doing nothing. You keep one interface, and any node can stand in for any other, but you have bolted a method onto the leaf that is a lie: file.add(x) compiles, runs, and means nothing. The safe version puts child-management only on the container. A file simply has no add, so nobody can call it by mistake, but now leaves and containers have different interfaces and the caller is back to knowing which one it holds before it can add a child.
My opinion, earned from shipping both: keep the operations you call in a loop over the tree uniform, and do not force child-management onto leaves. The split is not arbitrary. draw, size, bounds, count are read at traversal time by code that genuinely does not know or care what it is holding. Those must be uniform, or the whole pattern evaporates. But add and remove are used at construction time by code that is deliberately building a folder and knows full well it is building one. That code is not iterating blindly. It said folder(...) three lines up.
So put add on containers only, and on the rare occasion some generic code really does need “attach this child if the target can hold children”, ask honestly rather than shipping a leaf that throws:
// operating over the tree: uniform, zero type checks
root.draw(ctx);
const total = root.bounds();
// building the tree: this code KNOWS it is making a container
const g = group().add(rect(0, 0, 10, 10)).add(circle(30, 5, 4));
// the rare generic case: ask for the capability, do not fake it on leaves
if (typeof node.add === "function") node.add(child);
If you write TypeScript, the safe split stops being a burden, because a discriminated union makes “which node am I holding” a question the compiler answers. Reach for a union type and the narrowing is automatic:
type Node =
| { kind: "file"; size(): number }
| { kind: "folder"; entries: Node[]; size(): number; add(n: Node): void };
node.size(); // fine on either variant
if (node.kind === "folder") node.add(child); // add() only exists here
Walking the tree in an order
Folding an operation up the tree (sum, union, clone) is one job, and the container’s method does it. Enumerating the nodes in a particular order is a different job. When you want “find the first node matching X” or “render in paint order” or “flatten to a list”, you are traversing, and traversal has an order.
Depth-first dives into a child completely before it looks at the next sibling. Breadth-first visits an entire level before descending. Same tree, different sequence, and which one you want depends on the task: depth-first for anything that mirrors nesting (rendering, serialization, “closest matching ancestor”), breadth-first for “nearest match to the root” or level-by-level layout.
You do not have to hand-roll either. Depth-first is a generator that delegates to itself, four lines, and it turns the whole tree into something you can for...of:
function* walk(node) {
yield node;
if (node.children) {
for (const child of node.children) yield* walk(child);
}
}
const firstBig = [...walk(root)].find((n) => n.kind === "file" && n.bytes > 1e6);
That is the natural pairing: composite gives you the tree and the uniform operations, and the iterator pattern gives you a way to stream its nodes without every caller re-deriving the walk. Breadth-first is the same idea with a queue instead of the call stack: push the root, and while the queue is not empty, shift a node and push its children.
Deep trees and deep copies
Two practical things break the tidy recursion, and both are worth knowing before they bite.
The first is stack depth. Every recursive call sits on the JavaScript call stack, and that stack is finite. V8 (Chrome and Node) throws RangeError: Maximum call stack size exceeded somewhere around 11,000 nested frames, Firefox tolerates more, Safari more still, and the exact number shifts with how many locals each frame holds. Do not build anything that leans on the figure. For a UI tree or a normal project directory you will never come close. But a pathologically deep tree, an untrusted directory that is really a chain a folder deep ten thousand times, or a hostile blob of nested JSON, can blow a naive recursive size() clean off the stack. When depth is unbounded and you did not create it, trade the elegant recursion for an explicit stack on the heap, which is limited only by memory:
// iterative depth-first: an array is the stack, so depth is bounded by RAM
function totalBytes(root) {
let total = 0;
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
if (node.children) {
for (const child of node.children) stack.push(child);
} else {
total += node.bytes;
}
}
return total;
}
Notice the cost. This version pokes at node.children directly, so it gave up the uniform interface to gain stack safety. That is the right trade only when the input is genuinely hostile or unbounded. For your own bounded trees, recursion is clearer and you should keep it.
The second is cloning. You will eventually want a deep copy of a tree, and JavaScript ships structuredClone for exactly that. It deep-copies plain objects and arrays, follows nested structure, and even handles cycles, which is more than JSON.parse(JSON.stringify(...)) ever could. But read the fine print before you reach for it on a composite.
When a composite is the wrong call
The pattern is so clean that it invites overuse. It earns its place only when the hierarchy is genuinely recursive and the operations are genuinely uniform. Miss either half and you are adding indirection for nothing.
A fixed, shallow hierarchy does not need it. A page has sections, and a section has items. That is two levels and it will always be two levels. An array of sections, each with an array of items, and two ordinary loops, is clearer than a Leaf/Composite hierarchy pretending the nesting is open-ended. Composite pays off when a container can hold another container to arbitrary depth. If it cannot, you have a list of lists, and that is fine as a list of lists.
Leaves and containers that share no real operation should not share an interface. The whole point is that node.render() means something on both a leaf and a branch. If the only thing your two node types have in common is “they both exist in a tree”, forcing a common interface produces a bag of no-op methods, the transparency tax with nothing bought back. That is a sign the two things are not actually the same kind of thing and should not pretend to be.
If you only ever enumerate, you want an iterator, not a composite. Composite shines when operations fold over the structure (sum, union, render, clone). If every use is “give me all the nodes and I will do the work outside”, the tree does not need methods at all; it needs to be walkable. Build the iterator and skip the uniform interface.
Do not rebuild the DOM inside your own tree. You already have a battle-tested composite in the document. Wrapping every element in a bespoke Component class so you can call your own render() is what a UI framework does, and unless you are writing one, it is a lot of scaffolding to reinvent appendChild. Lean on composition over inheritance and the platform tree before you grow a parallel one.
Summary
- A composite gives leaves and containers the same interface, so a container implements each operation by delegating to its children. Caller code calls one method on any node and never checks “one thing or many”.
- The recursion is the mechanism, not a bonus.
folder.size()sumssize()over its entries; a child folder answers the same way, so the fold reaches the leaves with no base case, depth parameter, or manual stack in the caller. - You already use composites daily. The DOM is one (
ElementandTextare bothNode;textContentreads the whole subtree,cloneNode(true)clones it), and so is a file system. Reach for the pattern when your own data is that shape. - In JavaScript, skip the abstract base class. Factory functions returning objects with the shared methods (or plain objects, or classes where a class genuinely helps) express the pattern with less ceremony than an inheritance tree.
- The real tension is transparency vs safety: a fully uniform interface forces leaves to implement
add/removeand lie about them. Practical stance: keep the operations you call while traversing uniform, and put child-management only on containers, since the code building a tree already knows it is building a container. TypeScript’s discriminated unions make the safe split ergonomic. - Folding up the tree is one job; traversing it in an order is another. Depth-first mirrors nesting, breadth-first clears a level at a time, and a
yield*generator gives you either without hand-rolling. Composite pairs with the iterator pattern for exactly this. - Watch stack depth on untrusted or unbounded trees (V8 caps near 11,000 frames); switch to an explicit-stack iterative walk when the input is hostile. And remember
structuredClonecopies data, not methods, so a behavior tree needs its ownclone(). - Use it when the hierarchy is recursively nested and the operations are uniform across leaves and containers. Avoid it when the nesting is shallow and fixed, the node types share no real operation, or you only ever enumerate the nodes and never fold over them.