Tree-shaped data is represented as untyped records with a discriminating field; every operation walks the tree by switching on that field. Adding a new node kind requires editing every operation; adding a new operation requires re-implementing the switch.
Leaf and composite share an interface; the composite's implementation of any operation delegates to its children. Client code treats trees and leaves uniformly; new node kinds are new classes; new operations are new methods.
Before the pattern
function totalSize(node) {if (node.type === 'file') {return node.bytes;} else if (node.type === 'directory') {let total = 0;for (const child of node.children) {total += totalSize(child);}return total;}throw new Error(`unknown node type: ${node.type}`);}function nameOf(node) {if (node.type === 'file' || node.type === 'directory') return node.name;throw new Error(`unknown node type: ${node.type}`);}// Every traversal repeats the if/else; new node types touch every function.
After the pattern
class FileNode {constructor(name, bytes) {this.name = name;this.bytes = bytes;}totalSize() { return this.bytes; }}class DirectoryNode {constructor(name, children) {this.name = name;this.children = children;}totalSize() {return this.children.reduce((sum, child) => sum + child.totalSize(), 0);}}// Client treats leaf and composite uniformly:const root = new DirectoryNode('project', [new FileNode('README.md', 1024),new DirectoryNode('src', [new FileNode('index.js', 2048)]),]);const size = root.totalSize();
Repeated Switches at every traversal: totalSize switches on type, nameOf switches on type, render switches on type. Each switch grows in lockstep when a new kind ships; one missed switch ships as a runtime error on a code path the test suite may not exercise.
Leaf and composite must agree on the interface even when an operation makes no sense on one of them — leaves often implement add()/remove() child operations as throws or no-ops, exposing the divergence in shape behind the uniform interface. The pattern is at its sharpest when every operation is meaningful on both sides; it loses some elegance otherwise.
totalSize() is one expression: 'sum the children'; client code reads as `root.totalSize()` regardless of how deep the tree is. New node kinds land as new classes without editing existing traversals; new operations land as new methods without editing client code.
When leaf and composite genuinely diverge in their useful operations — composite needs add/remove, leaf does not — the uniform interface forces leaves to expose meaningless operations or throw on them. The transparency-vs-safety tradeoff (uniform-but-unsafe vs typed-but-discriminated) is real; pick deliberately.