Composite
Intermediate · 11 min read · ▶ live playground · ✦ checkpoint
The file browser can calculate one file's size. Point it at a folder and the same operation turns into if file, else recurse; rendering, searching, and permissions soon repeat that branch across the codebase.
The Composite pattern lets clients treat one object and a composition of objects through the same interface. A folder can stand wherever a file-system component is expected, but that uniformity adds recursive structure and can hide operations that do not make sense for every node.
Before: every operation knows the tree's variants
A type tag can represent the tree accurately. The pressure appears when every new operation must rediscover which entries are files and which are folders:
type Entry =
| {
kind: "file";
name: string;
bytes: number;
}
| {
kind: "folder";
name: string;
children: Entry[];
};
function size(entry: Entry): number {
if (entry.kind === "file") {
return entry.bytes;
}
return entry.children.reduce(
(total, child) => total + size(child),
0,
);
}
function render(entry: Entry, indent = 0): string {
const prefix = " ".repeat(indent);
if (entry.kind === "file") {
return `${prefix}${entry.name} (${entry.bytes} bytes)`;
}
const children = entry.children.map((child) => render(child, indent + 2));
return [`${prefix}${entry.name}/`, ...children].join("\n");
}size() and render() both branch and recurse. Add find(), compress(), or countFiles(), and the same traversal knowledge spreads again. A new entry kind reopens every operation that switches on kind.
The conditional is not automatically a defect. For a fixed data shape with two local operations, this union can remain the clearest representation. Composite becomes useful when clients should ask any node to perform the same operation without inspecting its kind.
After: put the recursive operation on each node
A component is the shared interface for operations available on every node. A leaf is a component with no children, so File answers an operation directly. A composite is a component that contains other components and answers by delegating to those children.
The type relationship is recursive: Component describes both one node and children stored inside another Component. CompositeNode is a narrower interface for components that can accept children; Folder implements it and owns the child collection.
class diagram
Files and folders share one recursive Component contract
- 1Component gives clients the same size and render operations at every level of the tree.
- 2File is a leaf: it has no children and returns its own byte count.
- 3CompositeNode extends the contract with child management that would be meaningless on a leaf.
- 4Folder implements the composite role and delegates size and render across its children.
- 5The ownership edge returns to Component: a tree node contains more nodes of the same contract.
The filled composition diamond reads “Folder has Components.” Those children may be files or more folders. Neither size() nor a client has to ask which kind it received.
export {};
interface Component {
size(): number;
render(indent?: number): string;
}
interface CompositeNode extends Component {
add(child: Component): CompositeNode;
}
class File implements Component {
private name: string;
private bytes: number;
public constructor(name: string, bytes: number) {
this.name = name;
this.bytes = bytes;
}
public size(): number {
return this.bytes;
}
public render(indent = 0): string {
const prefix = " ".repeat(indent);
return `${prefix}${this.name} (${this.bytes} bytes)`;
}
}
class Folder implements CompositeNode {
private name: string;
private children: Component[] = [];
public constructor(name: string) {
this.name = name;
}
public add(child: Component): this {
this.children.push(child);
return this;
}
public size(): number {
return this.children.reduce(
(total, child) => total + child.size(),
0,
);
}
public render(indent = 0): string {
const prefix = " ".repeat(indent);
const descendants = this.children.map((child) =>
child.render(indent + 2),
);
return [`${prefix}${this.name}/`, ...descendants].join("\n");
}
}
const source = new Folder("src")
.add(new File("index.ts", 500))
.add(new File("styles.css", 300));
const project = new Folder("project")
.add(new File("README.md", 120))
.add(source);
console.log(`${project.size()} bytes`);
console.log(project.render());920 bytes project/ README.md (120 bytes) src/ index.ts (500 bytes) styles.css (300 bytes)
project.size() delegates to its two children. The file returns 120; the nested folder delegates again and returns 500 + 300. Every level speaks Component, so the same call works for a leaf, one folder, or the entire tree.
render() follows the same structure. Files contribute one line, while folders combine their own line with the results from each child. The client invokes one operation and lets polymorphism drive the recursion.
Where should add and remove live?
A transparent composite interface puts child methods such as add() and remove() on Component. Clients can build a tree without checking node kinds, but every leaf must then implement meaningless methods—often by throwing an error or doing nothing.
A safe composite interface keeps child methods on composites and reserves the shared interface for operations every node can honor. That is the choice above. It makes leaves truthful, but code holding only a Component cannot add a child without first knowing that it has a composite.
Neither option removes the trade-off. Choose based on which uniformity matters: uniform tree operations or uniform tree construction. Do not make File.add() lie merely to achieve a perfectly symmetrical interface.
Mutable composites carry another cost. If code adds a folder beneath itself or beneath one of its descendants, size() can recurse forever. A production tree with untrusted edits needs an ownership rule or a cycle check at add().
When NOT to use it
If the hierarchy is shallow and fixed, an explicit list is easier to read. A page with three known widgets may need header.render(), body.render(), and footer.render(), not a recursive component framework.
The original discriminated union is also a strong choice when data is serialized, variants are stable, and operations live together. Composite moves operations into node classes and introduces allocation, delegation, and mutation rules. Those costs are not repaid by a tree that never grows or by clients that must treat leaves and containers differently anyway.
Avoid forcing one broad interface across nodes with unrelated capabilities. If only folders can calculate quotas or only files can stream bytes, keep those operations on the honest types or split the contract. Uniform treatment is useful only for behavior that is truly uniform.
Checkpoint
Answer all three to mark this lesson complete
Composite lets one call flow through a whole tree. Next, Decorator keeps that shared-interface idea but wraps one object at a time to layer responsibilities dynamically.