// ───────────────────────────────────────────────────────────────────────────── // COMPOSITE [Structural] // ───────────────────────────────────────────────────────────────────────────── // 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 want `node.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 & // `children` composition; ASTs (Babel/ESLint nodes); Express `Router` mounted inside `app` // (routers contain routers & routes, all `.handle(req, res, next)`); menu/tree UI components; // scene graphs (three.js `Object3D.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. // ───────────────────────────────────────────────────────────────────────────── '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 identically 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); root.remove(nodeModules); assert.equal(root.size(), 2300); // aggregate updates as the tree changes assert.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}`); }; assert.equal(size(tree), 2300); assert.equal(size(tree.children[0]), 1200); // leaf and branch — same call assert.deepEqual([...walk(tree)], ['project', 'project/README.md', 'project/src', 'project/src/index.js', 'project/src/utils.js']); // React mental model: a component tree IS a composite — every node renders; containers render children const render = (el) => (typeof el === 'string' ? el : `<${el.type}>${(el.children ?? []).map(render).join('')}`); const ui = { type: 'ul', children: [{ type: 'li', children: ['a'] }, { type: 'li', children: ['b'] }] }; assert.equal(render(ui), ''); assert.equal(render('text'), 'text'); console.log('ok: design-patterns/structural/composite.js');