GUIDE · Structural
Composite
Compose objects into tree structures to represent part-whole hierarchies; let clients treat individual objects and compositions of objects uniformly.
At a glance
- INTENT
- Compose objects into tree structures to represent part-whole hierarchies; let clients treat individual objects and compositions of objects uniformly.
- PROBLEM
- Code is littered with
if (node.isFolder) recurse() else handle()— you wantnode.size()to just work whether it's a file or a folder of folders of files. - SAY
- Participants: Component (common interface: size(), find()), Leaf (File — no children), Composite (Directory — has children, delegates & aggregates), Client. Pitch: "a tree where every node, leaf or branch, answers the same calls."
- SEEN IN
- The DOM (Element/Text share the Node interface; containers vs leaves); React element trees &
childrencomposition; ASTs (Babel/ESLint nodes); ExpressRoutermounted insideapp(routers contain routers & routes, all.handle(req, res, next)); menu/tree UI components; scene graphs (three.jsObject3D.add). - VS
- Decorator wraps ONE child and adds behavior; Composite holds MANY children and aggregates. Chain of Responsibility is a LINE that passes along; Composite is a TREE that fans out. Visitor/Iterator are how you walk a Composite.
The idea#
Picture a grocery sack. A granola bar weighs 40 grams. A lunch bag holds that bar and an apple, and the bag's weight is the sum of what is inside. The sack holds the lunch bag and a bag of chips, and you ask the sack the same way. The question is weight. A bar, a bag, and a sack all take it. Bags can hold bags. That is a tree.
A composite is that tree. Objects form a part-whole hierarchy, and the client treats a single object and a group of objects through one interface. The component is the shared interface, here weight, and on the sheet size, find, and a printable form. A leaf has no children. It answers from its own data. A composite has children. It answers by asking each child and combining the answers. The client does not ask which kind it is holding before it calls.
In code the usual tree is files and folders. A file's size is its bytes. A folder's size sums its children, and a child folder does the same. find follows that rule. The call stays node.size() whether the node is a readme or the whole project.
Why it matters#
The bug this pattern prevents is a pile of type checks at every call site. if (node.isFolder) recurse() else handle() gets copied into size, into search, and into printing. Miss one branch and a folder is treated as a file. Put the operation on the shared interface and the tree walks itself. Remove a folder and the next size follows the tree as it is now.
You already use this without the textbook name. The DOM is a composite: elements and text share a node interface, and only some nodes have children. React trees work the same way. A container renders by rendering its children. Babel and ESLint walk AST nodes through one shared shape. An Express Router mounted on an app contains routers and routes, and all of them handle a request. Menu trees do this, and so does a three.js scene graph, where add puts one object inside another of the same family.
A decorator is the neighbor that wraps a single child and adds behavior, like foil around one bar. A composite holds many children and aggregates. A chain of responsibility is a line that passes along. A composite is a tree, and the call fans out. Visitor and iterator are how you walk a composite when you want the walk written outside the nodes.
A small example#
function snack(name, grams) { return { name: name, weight: () => grams };}function bag(name, items) { return { name: name, weight: () => items.reduce((sum, item) => sum + item.weight(), 0), };}const bar = snack('granola', 40);const lunch = bag('lunch', [bar, snack('apple', 180)]);const sack = bag('sack', [lunch, snack('chips', 50)]);const weights = [bar.weight(), lunch.weight(), sack.weight()];weights is 40, then 220, then 270. One question, three levels. The sack never inspects whether a child is a bar or a bag. It only calls weight.
What the code below is doing#
The sheet shows the same idea twice.
The first shape uses classes so the book names are visible. FsNode is the component. size throws on the base. find keeps this node when the predicate matches. toString prints the name, the size, and indentation. File is the leaf. Its size is its bytes, and inherited find checks only itself. Directory is the composite. add and remove edit the children. children returns a copy of that list. size sums child.size(), and that recursion is the pattern. find checks the directory, then each child, into one array. toString prints itself, then each child one level deeper. The client builds a readme of 1200 bytes, a src directory with index.js at 800 and utils.js at 300, and node_modules holding left-pad holding a 90 byte file, all under project. File size is 1200, src is 1100, and the root is 2390. Finding .js names returns src's index, utils, then the nested index. Finding directories returns four: project, src, node_modules, and left-pad. Removing node_modules drops the root to 2300, and the printed tree is the project, the readme, and src with its two files.
The second shape is how you would write it on a normal day: nested plain objects and recursive functions. A branch has children. A leaf has bytes. size sums children, or returns bytes when there are none. The project is 2300. The readme leaf, same call, is 1200. walk yields a path, then each child path: the project, the readme, src, and the two files under src. The React picture under that is the same tree. render of a string returns the string. render of an element wraps its type around its rendered children. A ul of two li elements, each holding one letter, becomes those tags nested. Leaf and branch both go through render.
When you talk about it, say this: a tree where every node, leaf or branch, answers the same calls. The component is that shared interface, the leaf has no children, the composite holds children and aggregates, and the client does not care which one it is holding.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Classic shape (classes, so the participant names are visible)#
class FsNode { // Component constructor(name) { this.name = name; } size() { throw new Error('abstract'); } find(pred, out = []) { if (pred(this)) out.push(this); return out; } toString(depth = 0) { return `${' '.repeat(depth)}${this.name} (${this.size()})`; }}class File extends FsNode { // Leaf constructor(name, bytes) { super(name); this.bytes = bytes; } size() { return this.bytes; }}class Directory extends FsNode { // Composite #children = []; add(...nodes) { this.#children.push(...nodes); return this; } remove(node) { this.#children = this.#children.filter((c) => c !== node); return this; } get children() { return [...this.#children]; } size() { return this.#children.reduce((sum, c) => sum + c.size(), 0); } // recursion IS the pattern find(pred, out = []) { super.find(pred, out); for (const c of this.#children) c.find(pred, out); return out; } toString(depth = 0) { return [super.toString(depth), ...this.#children.map((c) => c.toString(depth + 1))].join('\n'); }} const readme = new File('README.md', 1200);const src = new Directory('src').add(new File('index.js', 800), new File('utils.js', 300));const nodeModules = new Directory('node_modules').add(new Directory('left-pad').add(new File('index.js', 90)));const root = new Directory('project').add(readme, src, nodeModules); // Client treats leaf and composite identicallyroot.remove(nodeModules);Verified by 7 asserts
assert.equal(readme.size(), 1200);assert.equal(src.size(), 1100);assert.equal(root.size(), 1200 + 1100 + 90);assert.deepEqual(root.find((n) => n.name.endsWith('.js')).map((n) => n.name), ['index.js', 'utils.js', 'index.js']);assert.equal(root.find((n) => n instanceof Directory).length, 4);assert.equal(root.size(), 2300); // aggregate updates as the tree changesassert.equal(root.toString(), [ 'project (2300)', ' README.md (1200)', ' src (1100)', ' index.js (800)', ' utils.js (300)',].join('\n'));Idiomatic JS shape: plain nested data + recursive functions (how JSON / AST / React trees are handled)#
const tree = { name: 'project', children: [ { name: 'README.md', bytes: 1200 }, { name: 'src', children: [{ name: 'index.js', bytes: 800 }, { name: 'utils.js', bytes: 300 }] }, ],};const size = (n) => (n.children ? n.children.reduce((s, c) => s + size(c), 0) : n.bytes);const walk = function* (n, path = n.name) { yield path; for (const c of n.children ?? []) yield* walk(c, `${path}/${c.name}`);}; // React mental model: a component tree IS a composite — every node renders; containers render childrenconst render = (el) => (typeof el === 'string' ? el : `<${el.type}>${(el.children ?? []).map(render).join('')}</${el.type}>`);const ui = { type: 'ul', children: [{ type: 'li', children: ['a'] }, { type: 'li', children: ['b'] }] };Verified by 5 asserts
assert.equal(size(tree), 2300);assert.equal(size(tree.children[0]), 1200); // leaf and branch — same callassert.deepEqual([...walk(tree)], ['project', 'project/README.md', 'project/src', 'project/src/index.js', 'project/src/utils.js']);assert.equal(render(ui), '<ul><li>a</li><li>b</li></ul>');assert.equal(render('text'), 'text');See also