Loading learning content...
In the previous page, we examined the problem: part-whole hierarchies require uniform treatment of individual objects and compositions. Naive approaches scatter type-checking throughout the codebase, violate the Open-Closed Principle, and create maintenance nightmares.
The Composite Pattern's solution is deceptively simple: define a common interface for both leaves and composites, and have composites contain references to that interface rather than concrete types. This single structural decision transforms the design from brittle type-switching to elegant polymorphism.
By the end of this page, you will understand the Composite Pattern's core solution: the tree structure with a common interface. You'll see how this structure enables uniform treatment, transparent recursion, and painless extensibility—solving every problem identified in the previous page.
The Composite Pattern rests on one fundamental insight:
If both leaves and composites implement the same interface, and composites hold references to that interface (not to concrete types), then clients can work with trees of arbitrary depth and composition without type-checking.
Let's unpack this:
This is polymorphism applied to recursive structures. Just as a graphics system can store a List<Shape> and call draw() on each without knowing the concrete types, a composite can store a List<Component> and call operations without knowing whether each is a leaf or another composite.
When a composite's operation (like getSize()) iterates over its children calling the same operation, and some of those children are themselves composites, recursion happens automatically. The client doesn't write recursive code—the structure IS recursive.
Let's transform our file system example from the naive approach to the Composite Pattern. Watch how the design changes:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
// Step 1: Define the Component interface// This is what ALL nodes (leaves and composites) implement interface FileSystemNode { getName(): string; getSize(): number; // Child management (we'll discuss these options later) add(child: FileSystemNode): void; remove(child: FileSystemNode): void; getChildren(): FileSystemNode[];} // Step 2: Implement Leaf (File)// Files are leaves - they have no children class File implements FileSystemNode { constructor( private name: string, private size: number ) {} getName(): string { return this.name; } getSize(): number { // Leaf behavior: return our own size directly return this.size; } // Child operations don't apply to leaves add(child: FileSystemNode): void { throw new Error("Cannot add children to a file"); } remove(child: FileSystemNode): void { throw new Error("Cannot remove children from a file"); } getChildren(): FileSystemNode[] { return []; // No children }} // Step 3: Implement Composite (Folder)// Folders are composites - they contain children class Folder implements FileSystemNode { private children: FileSystemNode[] = []; constructor(private name: string) {} getName(): string { return this.name; } getSize(): number { // Composite behavior: aggregate from children // Notice: we don't type-check children! return this.children.reduce( (total, child) => total + child.getSize(), 0 ); } add(child: FileSystemNode): void { this.children.push(child); } remove(child: FileSystemNode): void { const index = this.children.indexOf(child); if (index !== -1) { this.children.splice(index, 1); } } getChildren(): FileSystemNode[] { return [...this.children]; // Return copy for safety }}Now observe what we've gained:
No type-checking in operations — Folder.getSize() calls child.getSize() without knowing if child is a File or another Folder
Automatic recursion — When getSize() is called on a deeply nested folder, the operation naturally recurses through the entire tree
Common interface — Both File and Folder implement FileSystemNode, enabling uniform client code
The real payoff of the Composite Pattern is in client code. Compare the naive approach with the Composite approach:
1234567891011121314151617181920212223242526272829303132
// Every operation requires type checking function printSize(node: File | Folder) { if (node instanceof File) { console.log( `${node.name}: ${node.size}b` ); } else if (node instanceof Folder) { let total = 0; for (const child of node.children) { total += getSize(child); // Recurse } console.log( `${node.name}: ${total}b` ); }} function getSize(node: File | Folder): number { if (node instanceof File) { return node.size; } else { let total = 0; for (const child of node.children) { total += getSize(child); } return total; }} // Adding new types requires modifying// ALL of these functions123456789101112131415161718192021222324252627282930313233
// No type checking anywhere function printSize( node: FileSystemNode) { console.log( `${node.getName()}: ${node.getSize()}b` );} function getTotalSize( nodes: FileSystemNode[]): number { return nodes.reduce( (sum, n) => sum + n.getSize(), 0 );} function findLargest( root: FileSystemNode): FileSystemNode { let largest = root; for (const child of root.getChildren()) { const candidate = findLargest(child); if (candidate.getSize() > largest.getSize()) { largest = candidate; } } return largest;} // New types plug in automatically!Key differences:
| Aspect | Naive Approach | Composite Pattern |
|---|---|---|
| Type parameters | Union types (File | Folder) | Single interface (FileSystemNode) |
| Operation logic | External, requires type checks | Internal, polymorphic |
| Recursion | Client must implement | Built into composite |
| New types | Modify every function | Just implement interface |
The Composite Pattern is fundamentally about leveraging polymorphism for recursive structures. The type system and runtime dispatch do the work that the naive approach forces onto the programmer.
The Composite Pattern creates a tree structure where:
Let's visualize this:
123456789101112131415161718192021222324252627
FileSystemNode (interface) ╱ ╲ ╱ ╲ File Folder (Leaf) (Composite) │ │ contains ▼ FileSystemNode[] (any mix of File and Folder) Concrete Example: project/ (Folder) ╱ │ ╲ ╱ │ ╲ src/ README docs/ (Folder) (File) (Folder) │ │ ┌────┴────┐ ┌───┴───┐ │ │ │ │ index.ts utils/ api.md guide/ (File) (Folder) (File) (Folder) │ │ math.ts intro.md (File) (File)Notice the key structural properties:
Uniform references — Every parent holds children as FileSystemNode[], not as specific types
Arbitrary depth — The tree can nest to any level because composites hold the same interface they implement
Mixed children — A folder can contain any mix of files and other folders
Recursive operations — Calling getSize() on 'project/' automatically computes sizes from every node in the entire tree
Let's trace exactly how a getSize() call propagates through a composite tree. Understanding this flow is key to understanding the pattern's power.
1234567891011121314151617
// Build the treeconst project = new Folder("project");const src = new Folder("src");const utils = new Folder("utils"); const readme = new File("README.md", 1000);const index = new File("index.ts", 2500);const math = new File("math.ts", 1500); project.add(src);project.add(readme);src.add(index);src.add(utils);utils.add(math); // Now call getSize() on rootconsole.log(project.getSize()); // What happens?Step-by-step execution of project.getSize():
123456789101112131415161718192021
project.getSize() called│├─► Iterate over children: [src, readme]│├─► src.getSize() called [src is Folder - Composite]│ ├─► Iterate over children: [index, utils]│ ├─► index.getSize() called [index is File - Leaf]│ │ └─► Return 2500 (direct value)│ ├─► utils.getSize() called [utils is Folder - Composite]│ │ ├─► Iterate over children: [math]│ │ ├─► math.getSize() called [math is File - Leaf]│ │ │ └─► Return 1500 (direct value)│ │ └─► Return 1500 (sum of children)│ └─► Return 2500 + 1500 = 4000 (sum of children)│├─► readme.getSize() called [readme is File - Leaf]│ └─► Return 1000 (direct value)│└─► Return 4000 + 1000 = 5000 (sum of children) Final result: 5000 bytesThe client just called project.getSize(). They didn't write any loop, didn't check any types, didn't implement recursion. The tree structure and polymorphic dispatch handled everything automatically.
The pattern's power becomes clear when we add more operations. Each operation follows the same pattern: leaves do something directly, composites delegate to children.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
interface FileSystemNode { getName(): string; getSize(): number; getPath(): string; // Search operations find(name: string): FileSystemNode | null; findAll(predicate: (node: FileSystemNode) => boolean): FileSystemNode[]; // Mutation operations delete(): void; // Tree traversal forEach(callback: (node: FileSystemNode) => void): void; // Child management add(child: FileSystemNode): void; remove(child: FileSystemNode): void; getChildren(): FileSystemNode[];} class File implements FileSystemNode { constructor(private name: string, private size: number) {} getName(): string { return this.name; } getSize(): number { return this.size; } getPath(): string { return this.name; } find(name: string): FileSystemNode | null { // Leaf: check ourselves return this.name === name ? this : null; } findAll(predicate: (node: FileSystemNode) => boolean): FileSystemNode[] { // Leaf: return ourselves if we match return predicate(this) ? [this] : []; } delete(): void { // Leaf: just delete ourselves (implementation details omitted) console.log(`Deleting file: ${this.name}`); } forEach(callback: (node: FileSystemNode) => void): void { // Leaf: just call with ourselves callback(this); } add(child: FileSystemNode): void { throw new Error("Cannot add to file"); } remove(child: FileSystemNode): void { throw new Error("Cannot remove from file"); } getChildren(): FileSystemNode[] { return []; }} class Folder implements FileSystemNode { private children: FileSystemNode[] = []; constructor(private name: string) {} getName(): string { return this.name; } getPath(): string { return `${this.name}/`; } getSize(): number { // Composite: sum of children return this.children.reduce((sum, c) => sum + c.getSize(), 0); } find(name: string): FileSystemNode | null { // Composite: check ourselves, then delegate to children if (this.name === name) return this; for (const child of this.children) { const found = child.find(name); if (found) return found; } return null; } findAll(predicate: (node: FileSystemNode) => boolean): FileSystemNode[] { // Composite: collect matching from ourselves and all children const results: FileSystemNode[] = predicate(this) ? [this] : []; for (const child of this.children) { results.push(...child.findAll(predicate)); } return results; } delete(): void { // Composite: delete all children first (bottom-up), then ourselves for (const child of [...this.children]) { child.delete(); } console.log(`Deleting folder: ${this.name}`); } forEach(callback: (node: FileSystemNode) => void): void { // Composite: call with ourselves, then delegate to children callback(this); for (const child of this.children) { child.forEach(callback); } } add(child: FileSystemNode): void { this.children.push(child); } remove(child: FileSystemNode): void { const index = this.children.indexOf(child); if (index !== -1) this.children.splice(index, 1); } getChildren(): FileSystemNode[] { return [...this.children]; }}The pattern for every operation:
| In Leaf | In Composite |
|---|---|
| Operate on self | Operate on self + delegate to children |
| Return own value | Aggregate children's values |
| Mutate own state | Propagate mutation to children |
| O(1) typically | O(n) where n is subtree size |
Remember the explosion problem with naive type-checking? Adding a new type required modifying every operation. With the Composite Pattern, adding a new type is completely isolated:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// New type: Symbolic Link (acts like the file/folder it points to)class SymbolicLink implements FileSystemNode { constructor( private name: string, private target: FileSystemNode ) {} getName(): string { return this.name; } getSize(): number { // Follow the link return this.target.getSize(); } find(name: string): FileSystemNode | null { return this.name === name ? this : this.target.find(name); } // ... other methods delegate to target // Child management: links don't have children add(child: FileSystemNode): void { throw new Error("Cannot add to symbolic link"); } remove(child: FileSystemNode): void { throw new Error("Cannot remove from symbolic link"); } getChildren(): FileSystemNode[] { return []; } // Additional link-specific methods getTarget(): FileSystemNode { return this.target; }} // New type: Compressed Folder (different size calculation)class CompressedFolder implements FileSystemNode { private children: FileSystemNode[] = []; constructor( private name: string, private compressionRatio: number = 0.5 ) {} getName(): string { return this.name; } getSize(): number { // Compressed size is smaller than actual content const uncompressedSize = this.children.reduce( (sum, c) => sum + c.getSize(), 0 ); return Math.floor(uncompressedSize * this.compressionRatio); } // ... other operations work identically to Folder add(child: FileSystemNode): void { this.children.push(child); } remove(child: FileSystemNode): void { const index = this.children.indexOf(child); if (index !== -1) this.children.splice(index, 1); } getChildren(): FileSystemNode[] { return [...this.children]; }} // Usage: New types work everywhere immediatelyconst project = new Folder("project");const compressed = new CompressedFolder("archive", 0.3);const bigFile = new File("data.bin", 10000);const link = new SymbolicLink("shortcut", bigFile); compressed.add(bigFile);project.add(compressed);project.add(link); // All operations work without any changes!console.log(project.getSize()); // Works!project.forEach(n => console.log(n.getName())); // Works!console.log(project.find("data.bin")); // Works!We added two entirely new node types (SymbolicLink, CompressedFolder) without modifying a single line of existing code. All existing operations work automatically through polymorphism. This is the Open-Closed Principle: open for extension, closed for modification.
Let's verify that the Composite Pattern solves every requirement we identified in the previous page:
| Requirement | How Composite Pattern Satisfies It |
|---|---|
| Uniform Interface | All nodes implement FileSystemNode — clients work with interface only |
| Transparent Recursion | Composite operations iterate over children calling same interface method |
| Arbitrary Composition | Composites hold FileSystemNode[], allowing any mix of types to any depth |
| Extensibility | New types implement interface; existing code untouched |
| Type Safety | Interface conformance checked at compile time; runtime instanceof eliminated |
Client code now:
The structural relationship:
Component (Interface) ◄────── Client uses only this
▲
│ implements
├────────────┐
│ │
Leaf Composite ───────► contains Component[]
(recursive aggregation)
This is the Composite Pattern's core contribution: a simple structural arrangement that yields profound benefits in uniformity and extensibility.
The Composite Pattern can be implemented with variations depending on how strictly you want to enforce the leaf/composite distinction. Let's examine the spectrum:
The Gang of Four notes that this is a trade-off between transparency (uniformity) and safety. The right choice depends on your context: how important is uniform treatment vs. how important is catching type errors at compile time?
We've covered the Composite Pattern's solution comprehensively. Let's consolidate the key insights:
Component[], not specific types, allowing arbitrary composition of leaves and composites.What's next:
Now that we understand the pattern's core solution, we need to examine its participants in detail. The next page provides a rigorous structural analysis of the three key participants—Component, Leaf, and Composite—their responsibilities, relationships, and implementation considerations.
You now understand the Composite Pattern's core solution: a tree structure with a common interface that enables uniform treatment of leaves and composites. This simple structural arrangement eliminates type-checking, enables transparent recursion, and makes the system extensible. Next, we'll examine the pattern's participants in detail.