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.

callernode.draw(ctx)groupdraw() into childrenrectdraw() itselfgroupdraw() into childrencircledraw() itselftextdraw() itselfcontainer: delegates to its childrenleaf: does the work
Every node answers the same call. A leaf does the work itself; a container satisfies the call by delegating to its children, which may themselves be containers.

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.

returns 4,000returns 28,00020,0008,000project/sum = 32,000README.md4,000src/sum = 28,000app.js20,000util.js8,000
size() on a folder returns the sum of size() over its entries. Each leaf returns its own bytes; each folder folds its children, so totals add up the tree to 32,000 at the root.

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.

the DOMdiv (Element)p (Element)img (Element)Hi (Text)Element and Text are both Node.textContent reads the whole subtree.a file systemproject/src/logo.svgapp.jsa folder holds files and folders.size() folds over the entries.
Two trees you already know. Both are part-whole hierarchies where a container holds leaves and other containers, and both expose one interface that every node in the tree answers.

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.

interactiveA file tree where size() and count() recurse

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.

transparent: one interfaceNodedraw() size()add(child) remove(child)File (leaf)draw() size()add() throwsFolderdraw() size()add() realuniform, but leaf.add ismeaningless and can be misused.safe: split interfaceComponentdraw() size()File (leaf)draw() size()Folderdraw() size()add(child)remove(child)leaf cannot be misused, butyou gave up one shared interface.
Transparent puts add and remove on every node, so a leaf must fake them. Safe puts them only on containers, so a leaf cannot be misused but the interfaces differ.

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.

depth-first (pre-order)A1B2C5D3E4F6finish a branch, then its sibling:A B D E C Fbreadth-first (level order)A1B2C3D4E5F6clear a level, then descend:A B C D E F
The same six nodes, two visiting orders. Depth-first finishes a whole branch before the next sibling; breadth-first clears each level before going deeper.

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() sums size() 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 (Element and Text are both Node; textContent reads 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/remove and 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 structuredClone copies data, not methods, so a behavior tree needs its own clone().
  • 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.