---
title: Composite
source: 'https://academia.sh/en/courses/design-patterns/composite'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:15+00:00'
license: 'CC BY-SA 4.0'
---

# Composite

Handling tree structures uniformly: comparing, in a consolidated shipment tree, the arrangement where four calculations each repeat the type check in their own body against the arrangement putting the leaf and the node behind the same interface, by check count; measuring the files touched when a new node type and a new operation are added; the choice between transparency and safety.

The bridge separated two independent axes, but the shipment list it reported on was flat:
six shipments, all at the same level. The domain breaks this. A shipment can be a
**consolidated** shipment carrying other shipments inside it, and one of its parts can
itself be consolidated. Weight total, fee, delivery time, and part count are now computed
over a tree.

The problem is that every calculation asks "is this a single piece or consolidated" in its
own body. With four calculations, the same type check is written four times, and a fifth
calculation brings a fifth copy. The **composite** pattern removes the check from the
calculations by putting the leaf and the node behind the same interface. The measure is
type-check count; the pattern will be tested along two directions of growth — a new node
type and a new operation.

## Problem: The Tree's Shape Leaks into Every Calculation

The tree and the tariff are common to both arrangements. A consolidated node has its own
packaging weight, a leaf has a tier fee by postal code, and delivery days by zone.

```js
// tree.mjs — the shipment tree both arrangements compute, and the shared tariff
const TIER = [[1, 4990], [5, 8490], [15, 14990], [30, 24990]];
const COEFFICIENT = { 34: 100, "06": 115, 35: 120, 65: 145 };
const DAYS = { 34: 1, "06": 2, 35: 2, 65: 4 };

export const tierFee = (weight, postalCode) => {
  const t = TIER.find(([cap]) => weight <= cap) ?? [0, 24990];
  return Math.round((t[1] * (COEFFICIENT[postalCode.slice(0, 2)] ?? 165)) / 100);
};

export const zoneDays = (postalCode) => DAYS[postalCode.slice(0, 2)] ?? 5;

export const TREE = {
  type: "consolidated", code: "K-1", packaging: 0.5, parts: [
    { type: "package", code: "G-1", weight: 0.8, postalCode: "34100" },
    { type: "consolidated", code: "K-2", packaging: 0.3, parts: [
      { type: "package", code: "G-2", weight: 12, postalCode: "06500" },
      { type: "package", code: "G-3", weight: 3, postalCode: "35400" },
    ] },
    { type: "package", code: "G-4", weight: 18, postalCode: "06800" },
  ],
};
```

In the first arrangement, four calculations are four separate files, and all four begin
with the same check.

```js
// check/weight.mjs — every operation repeats the type check in its own body
export function weight(g) {
  if (g.type === "consolidated") return g.packaging + g.parts.reduce((t, p) => t + weight(p), 0);
  return g.weight;
}
```

```js
// check/fee.mjs — a consolidated shipment gets a five percent volume discount from three parts on
import { tierFee } from "../tree.mjs";

export function fee(g) {
  if (g.type === "consolidated") {
    const total = g.parts.reduce((t, p) => t + fee(p), 0);
    return g.parts.length >= 3 ? Math.round(total * 0.95) : total;
  }
  return tierFee(g.weight, g.postalCode);
}
```

```js
// check/duration.mjs — a consolidated shipment takes as long as its slowest part, plus one transfer day
import { zoneDays } from "../tree.mjs";

export function duration(g) {
  if (g.type === "consolidated") return Math.max(...g.parts.map(duration)) + 1;
  return zoneDays(g.postalCode);
}
```

```js
// check/parts.mjs — the leaf count in the tree
export function partCount(g) {
  if (g.type === "consolidated") return g.parts.reduce((t, p) => t + partCount(p), 0);
  return 1;
}
```

All four files know two things at once: the calculation itself, and how the tree gets
traversed. The second piece of knowledge is identical across all four.

## Solution: Leaf and Node on the Same Interface

The composite's solution is to give the traversal knowledge to the node itself. The shared
interface consists of four methods, and both leaf and node satisfy all four.

```js
// composite/package.mjs — leaf: answers all four operations itself
import { tierFee, zoneDays } from "../tree.mjs";

export class Package {
  constructor({ code, weight, postalCode }) {
    this.code = code;
    this.kg = weight;
    this.postalCode = postalCode;
  }
  weight() { return this.kg; }
  fee() { return tierFee(this.kg, this.postalCode); }
  duration() { return zoneDays(this.postalCode); }
  partCount() { return 1; }
}
```

```js
// composite/consolidated.mjs — node: asks its parts through the same interface, does not know their type
export class Consolidated {
  constructor({ code, packaging, parts }) {
    this.code = code;
    this.packaging = packaging;
    this.parts = parts;
  }
  weight() { return this.packaging + this.parts.reduce((t, p) => t + p.weight(), 0); }
  fee() {
    const total = this.parts.reduce((t, p) => t + p.fee(), 0);
    return this.parts.length >= 3 ? Math.round(total * 0.95) : total;
  }
  duration() { return Math.max(...this.parts.map((p) => p.duration())) + 1; }
  partCount() { return this.parts.reduce((t, p) => t + p.partCount(), 0); }
}
```

The node's body has no type check for `p`: a part can be a leaf or another consolidated
shipment. The check is pulled into a single place — where the object tree gets built from
data.

```js
// composite/main.mjs — the only place the type check remains: builds the object tree from data
import { Package } from "./package.mjs";
import { Consolidated } from "./consolidated.mjs";

const NODE = { consolidated: Consolidated };

export const build = (data) => data.type === "package"
  ? new Package(data)
  : new NODE[data.type]({ ...data, parts: data.parts.map(build) });
```

```js
// run.mjs — do the two arrangements give the same four results for the same tree
import { TREE } from "./tree.mjs";
import { weight } from "./check/weight.mjs";
import { fee } from "./check/fee.mjs";
import { duration } from "./check/duration.mjs";
import { partCount } from "./check/parts.mjs";
import { build } from "./composite/main.mjs";

const n = build(TREE);
const a = [weight(TREE).toFixed(2), fee(TREE), duration(TREE), partCount(TREE)];
const b = [n.weight().toFixed(2), n.fee(), n.duration(), n.partCount()];
console.log(`check    : weight=${a[0]} kg  fee=${a[1]}  duration=${a[2]} days  parts=${a[3]}`);
console.log(`composite: weight=${b[0]} kg  fee=${b[1]}  duration=${b[2]} days  parts=${b[3]}`);
console.log(`mismatched results = ${a.filter((v, i) => String(v) !== String(b[i])).length} / 4`);
```

```
check    : weight=34.60 kg  fee=58098  duration=4 days  parts=4
composite: weight=34.60 kg  fee=58098  duration=4 days  parts=4
mismatched results = 0 / 4
```

At two levels of depth, with three leaves and one inner node, all four calculations agree.
The difference is in how many checks there are.

```js
// measure.mjs — measures the type check count and the number of files aware of the tree's inner shape
import { readdirSync, readFileSync } from "node:fs";

for (const dir of ["check", "composite"]) {
  const files = readdirSync(dir).filter((d) => d.endsWith(".mjs")).sort();
  let check = 0, shape = 0, line = 0;
  for (const d of files) {
    const text = readFileSync(`${dir}/${d}`, "utf8");
    check += (text.match(/type === /g) ?? []).length;
    if (/\bparts\b/.test(text)) shape += 1;
    line += text.split("\n").filter((s) => s.trim() !== "" && !s.trim().startsWith("//")).length;
  }
  console.log(`${dir.padEnd(9)} files=${files.length}  type checks=${check}  ` +
    `files aware of tree shape=${shape}  lines=${line}`);
}
```

```
check     files=4  type checks=4  files aware of tree shape=4  lines=21
composite files=3  type checks=1  files aware of tree shape=2  lines=32
```

The type check drops from 4 to 1; the number of files aware of the tree's inner shape (the
`parts` field) drops from 4 to 2. The composite arrangement is 11 lines longer — that is
the pattern's upfront cost.

## When a New Node Type Is Added

The first direction of growth is a new node type. A pallet departs from a consolidated
shipment in three points: a 25 kg tare weight, a fixed 3500-cent pallet fee, and a two-day
transfer. A pallet node enters the tree.

```sh
mkdir -p new-node-type && cp -r tree.mjs check composite run.mjs measure.mjs new-node-type/
ls new-node-type
```

```
check
composite
measure.mjs
run.mjs
tree.mjs
```

```js
// new-node-type/composite/pallet.mjs — second node type: tare weight, fixed pallet fee, two-day transfer
export class Pallet {
  constructor({ code, parts }) {
    this.code = code;
    this.parts = parts;
  }
  weight() { return 25 + this.parts.reduce((t, p) => t + p.weight(), 0); }
  fee() { return 3500 + this.parts.reduce((t, p) => t + p.fee(), 0); }
  duration() { return Math.max(...this.parts.map((p) => p.duration())) + 2; }
  partCount() { return this.parts.reduce((t, p) => t + p.partCount(), 0); }
}
```

In the check arrangement, all four calculations each gain a branch. The script below puts
the pallet in the tree, edits all four files, writes the new type into the node registry,
and counts both arrangements.

```sh
cd new-node-type
sed -i.y 's#    { type: "package", code: "G-4", weight: 18, postalCode: "06800" },#    { type: "pallet", code: "P-1", parts: [\n      { type: "package", code: "G-4", weight: 18, postalCode: "06800" },\n      { type: "package", code: "G-5", weight: 6, postalCode: "65100" },\n    ] },#' tree.mjs
sed -i.y 's#^  if (g.type === "consolidated") return g.packaging#  if (g.type === "pallet") return 25 + g.parts.reduce((t, p) => t + weight(p), 0);\n&#' check/weight.mjs
sed -i.y 's#^  if (g.type === "consolidated") {#  if (g.type === "pallet") return 3500 + g.parts.reduce((t, p) => t + fee(p), 0);\n&#' check/fee.mjs
sed -i.y 's#^  if (g.type === "consolidated") return Math.max#  if (g.type === "pallet") return Math.max(...g.parts.map(duration)) + 2;\n&#' check/duration.mjs
sed -i.y 's#^  if (g.type === "consolidated") return g.parts.reduce#  if (g.type === "pallet") return g.parts.reduce((t, p) => t + partCount(p), 0);\n&#' check/parts.mjs
sed -i.y -e 's#^import { Consolidated } from "./consolidated.mjs";#&\nimport { Pallet } from "./pallet.mjs";#' \
         -e 's#^const NODE = { consolidated: Consolidated };#const NODE = { consolidated: Consolidated, pallet: Pallet };#' composite/main.mjs
rm -f *.y check/*.y composite/*.y
node run.mjs
node measure.mjs
for d in check composite; do
  echo "$d: edited files=$(diff -rq "../$d" "$d" | grep -c '^Files ')  new files=$(diff -rq "../$d" "$d" | grep -c '^Only in ')  added lines=$(diff -rN "../$d" "$d" | grep '^>' | grep -cvE '^> *(//|$)')"
done
```

```
check    : weight=65.60 kg  fee=82072  duration=7 days  parts=5
composite: weight=65.60 kg  fee=82072  duration=7 days  parts=5
mismatched results = 0 / 4
check     files=4  type checks=8  files aware of tree shape=4  lines=25
composite files=4  type checks=1  files aware of tree shape=3  lines=43
check: edited files=4  new files=0  added lines=4
composite: edited files=1  new files=1  added lines=12
```

Four existing files were edited in the check arrangement and the type check went from 4 to
8; in the composite arrangement one file was edited (the node registry) and the check count
stayed at 1. The four edited bodies also carried the logic of two types that already worked
correctly; the 12 added lines, by contrast, sit in a new file. The check arrangement's line
cost is low (4 versus 12) because each file gained a single line — but all four of those
single lines entered an existing condition chain.

One detail shows the check arrangement's hidden risk: `parts.mjs`'s pallet branch is
identical to its consolidated branch. Rewriting the check in this file as `g.type !==
"package"` looks shorter, but a third node type would make that file silently answer wrong.
The composite arrangement has no such shortcut, because the type itself gives the answer.

## When a New Operation Is Added

The second direction of growth is the reverse: a new operation, the heaviest leaf's code.
Since the tree does not change, the base version is copied fresh.

```sh
mkdir -p new-operation && cp -r tree.mjs check composite measure.mjs new-operation/
ls new-operation/check
```

```
duration.mjs
fee.mjs
parts.mjs
weight.mjs
```

```js
// new-operation/check/heaviest.mjs — fifth operation: the heaviest leaf; a type check once more
export function heaviestPart(g) {
  if (g.type === "consolidated") {
    return g.parts.map(heaviestPart).reduce((a, b) => (b[1] > a[1] ? b : a));
  }
  return [g.code, g.weight];
}
```

```sh
cd new-operation
sed -i.y 's#^  partCount() { return 1; }#&\n  heaviestPart() { return [this.code, this.kg]; }#' composite/package.mjs
sed -i.y 's#^  partCount() { return this.parts.reduce((t, p) => t + p.partCount(), 0); }#&\n  heaviestPart() {\n    return this.parts.map((p) => p.heaviestPart()).reduce((a, b) => (b[1] > a[1] ? b : a));\n  }#' composite/consolidated.mjs
rm -f composite/*.y
cat > run2.mjs <<'RUN'
// new-operation/run2.mjs — does the fifth operation find the same leaf in both arrangements
import { TREE } from "./tree.mjs";
import { heaviestPart } from "./check/heaviest.mjs";
import { build } from "./composite/main.mjs";

console.log(`check    : heaviest = ${heaviestPart(TREE).join(" ")}`);
console.log(`composite: heaviest = ${build(TREE).heaviestPart().join(" ")}`);
RUN
node run2.mjs
for d in check composite; do
  echo "$d: edited files=$(diff -rq "../$d" "$d" | grep -c '^Files ')  new files=$(diff -rq "../$d" "$d" | grep -c '^Only in ')  added lines=$(diff -rN "../$d" "$d" | grep '^>' | grep -cvE '^> *(//|$)')"
done
```

```
check    : heaviest = G-4 18
composite: heaviest = G-4 18
check: edited files=0  new files=1  added lines=6
composite: edited files=2  new files=0  added lines=4
```

The direction reversed: the new operation touched no existing file in the check
arrangement, while both classes were edited in the composite arrangement. With three node
types, three classes would have been edited. This is the **expression problem** defined in
the Mixing Paradigms lesson of the Programming Paradigms course: the composite makes adding
a new type cheaper (4 edited files → 1), and adding a new operation more expensive (0 edited
files → 2). Choosing the pattern rests on which axis grows more often, and the source of
that measure is the change log.

## Cost and When Not to Apply It

The composite has three costs. The first is the upfront line cost: 21 lines against 32, and
two new types. The second is the cost paid on the operation axis — every new operation
touches every node type's body. The third is the interface's scope. The shared interface
here consists of four queries; a node operation like `add` was not put into it. Had it
been, the leaf would either silently ignore it or throw: a choice between **transparency**
(leaf and node indistinguishable) and **safety** (no meaningless method on the leaf), and
safety was chosen here. The consequence is that code changing the tree must know the node
type.

The pattern does not pay off in two situations. If depth is **fixed** at one — the shipment
list is always a single level — there is no recursion and no type check; the composite only
adds two classes. If the operation count grows much faster than the node-type count, the
expensive side of the expression problem is hit; in that case keeping the calculations
separate from a single traversal routine over the tree edits fewer files.

## Summary

- The problem the composite solves is every calculation over a tree repeating the "leaf or
  node" check in its own body; the solution is putting leaf and node behind the same
  interface.
- For four calculations, the type check dropped from 4 to 1, and the files aware of the
  tree's inner shape from 4 to 2; the cost is 11 lines and two new types.
- The new node type (pallet) edited 4 existing files in the check arrangement and pushed
  the check count to 8; in the composite arrangement 1 file was edited, 1 new file was
  added, the check count stayed at 1, and all four calculations agreed across both
  arrangements.
- The new operation (heaviest leaf) showed the reverse: 0 existing files edited in the
  check arrangement, 2 in the composite arrangement — the two sides of the expression
  problem.
- No tree-changing method was put into the shared interface, so the leaf carries no
  meaningless method; safety was chosen over transparency. The pattern does not pay off
  when depth is fixed or growth on the operation axis dominates.

## Next Step

In the composite, a node asks its parts through the same interface as itself; what gets
wrapped is a **collection**, and wrapping builds the tree. The next question uses the same
wrapping technique on a single object, for a different purpose: a shipment's fee needs a
fuel surcharge, insurance, a contracted-customer discount, and tax applied in sequence.
These four additions are not wanted on every shipment, they toggle independently, and their
order changes the result. If every combination of options becomes a subclass, the class
count grows as a power of two in the option count. The next lesson measures this count for
four options, writes the decorator pattern that wraps the fee in the same interface and
layers new behavior onto it, and counts its cost in calls to trace and order sensitivity.
