Skip to content

Composite: treating a leaf and a group the same way

A leaf answers for itself, a container for everything inside it, and the caller never asks which one it holds.

Predict first

Before you read: predict

ExamHall's ECON-201 paper: every candidate answers the multiple choice and then one of two essays, so the marking scheme puts the paper out of 32. The rubric editor totals a rubric with maxScore; score.ts comes in three versions, and rubric.ts and main.ts are the same in every version. The files are below.

Which version makes main.ts print 32?

Code for this question
// rubric.tsexport type Criterion = { kind: 'criterion'; title: string; points: number };export type Section = { kind: 'section'; title: string; children: RubricNode[] };export type BestOf = { kind: 'bestOf'; title: string; children: RubricNode[] };export type RubricNode = Criterion | Section | BestOf;export const criteria = (n: RubricNode): Criterion[] => (n.kind === 'criterion' ? [n] : n.children.flatMap(criteria)); // score.ts (version 1)import type { RubricNode } from './rubric';export function maxScore(n: RubricNode): number {  switch (n.kind) {    case 'criterion': return n.points;    case 'section': return n.children.reduce((sum, c) => sum + maxScore(c), 0);    case 'bestOf': return Math.max(...n.children.map(maxScore));  }} // score.ts (version 2)import { criteria, type RubricNode } from './rubric';export function maxScore(n: RubricNode): number {  switch (n.kind) {    case 'criterion': return n.points;    case 'section': return n.children.reduce((sum, c) => sum + maxScore(c), 0);    case 'bestOf': return Math.max(...criteria(n).map((c) => c.points));  }} // score.ts (version 3)import { criteria, type RubricNode } from './rubric';export function maxScore(n: RubricNode): number {  switch (n.kind) {    case 'criterion': return n.points;    case 'section': return criteria(n).reduce((sum, c) => sum + c.points, 0);    case 'bestOf': return Math.max(...n.children.map(maxScore));  }} // main.tsimport type { RubricNode } from './rubric';import { maxScore } from './score';const paper: RubricNode = { kind: 'section', title: 'ECON-201 paper', children: [  { kind: 'criterion', title: 'Multiple choice', points: 20 },  { kind: 'bestOf', title: 'Essay: answer one', children: [    { kind: 'section', title: 'Essay A', children: [      { kind: 'criterion', title: 'Argument', points: 4 },      { kind: 'criterion', title: 'Evidence', points: 4 },      { kind: 'criterion', title: 'Style', points: 4 } ] },    { kind: 'section', title: 'Essay B', children: [      { kind: 'criterion', title: 'Model', points: 5 },      { kind: 'criterion', title: 'Data', points: 5 } ] } ] } ] };console.log(maxScore(paper));
Pick one

The 30-second version

Composite gives a leaf and a container one interface, so code asks any node the same question. A leaf answers from its own data; a container asks each child and combines the replies by its own rule. In TypeScript, a discriminated union whose container holds that union, plus one recursive function per question, is the same pattern.

A leaf answers for itself, a container for everything inside it, and the caller never asks which one it holds.

How it works

Step 1 of 12

Two kinds of node. Menus, file trees, rubrics, the DOM and React's component tree hold groups of items and further groups, nested to any depth.

Analogy: A hamper company's packing room

Packers fill gift hampers with jars, bottles and smaller hampers; the till prices whatever is put on the counter.

At the hamper company In code
the till asks "what does this cost?" of a single jar or of a whole hamper, the same way one interface (step 2)
a jar's price is printed on its label the leaf answers (step 3)
a hamper's price is what its contents cost, and an inner hamper is priced by its own contents the container delegates (step 4)
a "choose your drink" slot holds a wine and a juice; the buyer takes one of them home a container kind with its own rule (step 5)

In real terms: the caller holds a node and asks it a question; whether the answer comes from one leaf or from a thousand nodes below is the node's business.

Breaks down when: a real hamper cannot be packed inside itself, while a rubric typed in by an admin can contain its own parent (step 12).

Contrasting case: a single bottle sold over the counter: no hamper, no tree, one price.

Examples, run for real

4.1 ExamHall: a rubric of any depth

EXH-4840: the rubric editor totalled a rubric with two nested loops, sections and then their criteria. A final exam whose questions were split into parts had a maximum of 30, and the editor showed 15.

MR !5140 replaces the loops with a union and one recursive function:

// rubric.ts, MR !5140
export type Criterion = { kind: 'criterion'; title: string; points: number };
export type Section = { kind: 'section'; title: string; children: RubricNode[] };
export type RubricNode = Criterion | Section;

export function maxScore(n: RubricNode): number {
  switch (n.kind) {
    case 'criterion': return n.points;
    case 'section': return n.children.reduce((sum, c) => sum + maxScore(c), 0);
  }
}
4.1 EXH-4840 two nested loops: tsc no diagnostics · final exam 15 · quiz 18 · 11 code lines
4.1 MR !5140 union and one function: tsc no diagnostics · final exam 30 · quiz 18 · 9 code lines
4.1 the same rubric as an interface and two classes: tsc no diagnostics · final exam 30 · quiz 18 · 9 code lines

The loops reached two levels, so the parts of a split question were never asked (steps 1, 4). One recursive function answers at every depth, as the interface and its two classes do, with no class, constructor or this (step 6).

4.2 What the MR keeps

The editor's side panel shows the maximum of whatever the examiner selects: a criterion, a part, a question or the whole exam. It calls maxScore(selected) for each, with no check of which kind was selected (steps 2–3). The : number on maxScore is a check too: when a third kind joins RubricNode, tsc reports maxScore until its switch handles it (step 11):

4.2 MR !5140's rubric.ts with a third kind (BestOf) in RubricNode: tsc TS2366 rubric.ts:7

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A streaming app's "My list" page shows how many videos a viewer has saved; this viewer has saved 6. count.ts comes in three versions; library.ts and main.ts are the same in every version. The files are below.

Which version makes main.ts print 6 videos?

Code for this question
// library.tsexport type Video = { kind: 'video'; title: string; minutes: number };export type Folder = { kind: 'folder'; title: string; items: LibraryNode[] };export type LibraryNode = Video | Folder; // count.ts (version 1)import type { LibraryNode } from './library';export function countVideos(n: LibraryNode): number {  if (n.kind === 'video') return 1;  return n.items.reduce((sum, i) => sum + countVideos(i), 1);} // count.ts (version 2)import type { LibraryNode } from './library';export function countVideos(n: LibraryNode): number {  if (n.kind === 'video') return 1;  return n.items.reduce((sum, i) => sum + countVideos(i), 0);} // count.ts (version 3)import type { LibraryNode } from './library';export function countVideos(n: LibraryNode): number {  if (n.kind === 'folder' && n.items.length > 0) return n.items.reduce((sum, i) => sum + countVideos(i), 0);  return 1;} // main.tsimport type { LibraryNode } from './library';import { countVideos } from './count';const myList: LibraryNode = { kind: 'folder', title: 'My list', items: [  { kind: 'video', title: 'Pilot', minutes: 52 },  { kind: 'folder', title: 'Season 1', items: [    { kind: 'video', title: 'Arrival', minutes: 44 },    { kind: 'video', title: 'The Vault', minutes: 47 },    { kind: 'video', title: 'Ashes', minutes: 45 } ] },  { kind: 'folder', title: 'Watch later', items: [] },  { kind: 'folder', title: 'Docs', items: [    { kind: 'folder', title: 'Nature', items: [      { kind: 'video', title: 'Reefs', minutes: 50 },      { kind: 'video', title: 'Ice', minutes: 48 } ] } ] } ] };console.log(`${countVideos(myList)} videos`);
Pick one

Question 2 of 7

The same app shows a folder's running time with totalMinutes. minutes.ts comes in three versions; library.ts and main.ts are the same in every version, and every version prints 96 min when run. The build runs tsc with strict: true. The files are below.

Which version does tsc compile with no diagnostics?

Code for this question
// library.tsexport type Video = { kind: 'video'; title: string; minutes: number };export type Folder = { kind: 'folder'; title: string; items: LibraryNode[] };export type LibraryNode = Video | Folder; // minutes.ts (version 1)import type { LibraryNode } from './library';export function totalMinutes(n: LibraryNode) {  if (n.kind === 'video') return n.minutes;  return n.items.reduce((total, item) => total + totalMinutes(item), 0);} // minutes.ts (version 2)import type { LibraryNode } from './library';export function totalMinutes(n: LibraryNode) {  if (n.kind === 'video') return n.minutes;  const total = n.items.reduce((sum, item) => sum + totalMinutes(item), 0);  return total;} // minutes.ts (version 3)import type { LibraryNode } from './library';export function totalMinutes(n: LibraryNode) {  if (n.kind === 'video') return n.minutes;  let total = 0;  for (const item of n.items) total += totalMinutes(item);  return total;} // main.tsimport type { LibraryNode } from './library';import { totalMinutes } from './minutes';const boxSet: LibraryNode = { kind: 'folder', title: 'Box set', items: [  { kind: 'video', title: 'Pilot', minutes: 52 },  { kind: 'folder', title: 'Season 1', items: [{ kind: 'video', title: 'Arrival', minutes: 44 }] } ] };console.log(`${totalMinutes(boxSet)} min`);
Pick one

Question 3 of 7

A streaming app lets a viewer add a video to whatever is selected in the library. The class part keeps add on the shared MediaItem interface; the union part's addTo takes a playlist. In both, the selected item is a single video. The files are below.

What do tsc and a run of main.ts report?

Code for this question
// classes.tsexport class NotAFolder extends Error {}export interface MediaItem { minutes(): number; add(item: MediaItem): void }export class Video implements MediaItem {  constructor(private readonly length: number) {}  minutes() { return this.length; }  add(item: MediaItem): void { throw new NotAFolder(); }}export class Collection implements MediaItem {  private readonly items: MediaItem[] = [];  minutes() { return this.items.reduce((sum, i) => sum + i.minutes(), 0); }  add(item: MediaItem) { this.items.push(item); }} // union.tsexport type Clip = { kind: 'clip'; minutes: number };export type Playlist = { kind: 'playlist'; items: Entry[] };export type Entry = Clip | Playlist;export function addTo(playlist: Playlist, entry: Entry) { playlist.items.push(entry); } // main.tsimport { Video, type MediaItem } from './classes';import { addTo, type Entry } from './union';const selectedItem: MediaItem = new Video(48);const selectedEntry: Entry = { kind: 'clip', minutes: 48 };const steps = [  () => selectedItem.add(new Video(12)),  () => addTo(selectedEntry, { kind: 'clip', minutes: 12 }),];for (const step of steps) {  try { step(); console.log('added'); } catch (e) { console.log((e as Error).constructor.name); }}
Pick one

Question 4 of 7

The app's navigation menu nests submenus inside submenus; the one in main.ts is four submenus deep. render.ts and main.ts are the same in every version; menu.ts comes in three versions. The build runs tsc with strict: true. The files are below.

With which version does tsc compile all three files with no diagnostics?

Code for this question
// menu.ts (version 1)export type Link = { kind: 'link'; label: string; href: string };export type Submenu = { kind: 'submenu'; label: string; items: MenuItem[] };export type MenuItem = Link | Submenu; // menu.ts (version 2)export type Link = { kind: 'link'; label: string; href: string };type Group<T> = { kind: 'submenu'; label: string; items: T[] };export type MenuItem = Link | Group<MenuItem>; // menu.ts (version 3)export type Link = { kind: 'link'; label: string; href: string };type Level3 = { kind: 'submenu'; label: string; items: Link[] };type Level2 = { kind: 'submenu'; label: string; items: (Link | Level3)[] };export type Submenu = { kind: 'submenu'; label: string; items: (Link | Level2)[] };export type MenuItem = Link | Submenu; // render.tsimport type { MenuItem } from './menu';export function render(item: MenuItem, depth = 0): string[] {  const pad = '-'.repeat(depth);  switch (item.kind) {    case 'link': return [`${pad}${item.label}`];    case 'submenu': return [`${pad}${item.label}/`, ...item.items.flatMap((i) => render(i, depth + 1))];  }} // main.tsimport type { MenuItem } from './menu';import { render } from './render';const menu: MenuItem = { kind: 'submenu', label: 'Browse', items: [  { kind: 'link', label: 'New', href: '/new' },  { kind: 'submenu', label: 'Genres', items: [    { kind: 'submenu', label: 'Drama', items: [      { kind: 'submenu', label: 'Period', items: [{ kind: 'link', label: 'Victorian', href: '/g/victorian' }] },    ] },  ] },] };console.log(render(menu).join(' | '));
Pick one

Question 5 of 7

Write outline(node) for the app's library, from a blank file that exports outline. A node is { kind: 'video', title, minutes } or { kind: 'folder', title, items }, nested to any depth.

  • It returns an array of lines, one per node, in the order the nodes appear, each indented two spaces per level below the node passed in.
  • The node passed in is not changed.

Write outline in full.

Question 6 of 7

A box office's seat map nests blocks of seats inside blocks. The venue adds standing areas: the Standing type and its place in SeatNode are new, free.ts, label.ts, ids.ts and main.ts are unchanged, and no standing area is on sale yet. The build runs tsc with strict: true. The files are below.

Which functions does tsc now report?

Code for this question
// seatmap.tsexport type Seat = { kind: 'seat'; id: string; free: boolean };export type Block = { kind: 'block'; name: string; parts: SeatNode[] };export type Standing = { kind: 'standing'; name: string; capacity: number; sold: number };export type SeatNode = Seat | Block | Standing; // free.tsimport type { SeatNode } from './seatmap';export function freeSeats(n: SeatNode): number {  switch (n.kind) {    case 'seat': return n.free ? 1 : 0;    case 'block': return n.parts.reduce((sum, p) => sum + freeSeats(p), 0);  }} // label.tsimport type { SeatNode } from './seatmap';export function label(n: SeatNode): string {  switch (n.kind) {    case 'seat': return `Seat ${n.id}`;    case 'block': return `${n.name} (${n.parts.length} parts)`;    default: return 'Other area';  }} // ids.tsimport type { SeatNode } from './seatmap';export function seatIds(n: SeatNode): string[] {  if (n.kind === 'seat') return [n.id];  return n.parts.flatMap(seatIds);} // main.tsimport type { SeatNode } from './seatmap';import { freeSeats } from './free';import { label } from './label';import { seatIds } from './ids';const stalls: SeatNode = { kind: 'block', name: 'Stalls', parts: [  { kind: 'seat', id: 'A1', free: true },  { kind: 'block', name: 'Row B', parts: [    { kind: 'seat', id: 'B1', free: true },    { kind: 'seat', id: 'B2', free: false } ] } ] };console.log(`${label(stalls)}: ${freeSeats(stalls)} free of ${seatIds(stalls).length}`);
Pick one

Question 7 of 7

A payments ledger keeps a card's balance as a tree of classes behind one LedgerNode interface. The team adds holds, money set aside for a pending charge, which lowers the balance: hold.ts is new, and main.ts puts a hold in the card's tree. The files are below.

What do tsc and a run of main.ts report?

Code for this question
// ledger.tsexport interface LedgerNode { name: string; balance(): number }export class Account implements LedgerNode {  constructor(readonly name: string, readonly cents: number) {}  balance() { return this.cents; }}export class Group implements LedgerNode {  constructor(readonly name: string, readonly children: LedgerNode[]) {}  balance(): number {    let total = 0;    for (const c of this.children) {      if (c instanceof Account) total += c.cents;      else if (c instanceof Group) total += c.balance();    }    return total;  }} // hold.tsimport type { LedgerNode } from './ledger';export class Hold implements LedgerNode {  constructor(readonly name: string, readonly cents: number) {}  balance() { return -this.cents; }} // main.tsimport { Account, Group } from './ledger';import { Hold } from './hold';const card = new Group('Card', [  new Account('Settled', 50000),  new Group('Pending', [new Hold('Hotel hold', 12000), new Account('Refund', 3000)]),]);console.log(`Card: ${card.balance()} cents`);
Pick one

Go deeper

Pro tips
  1. Check a tree where it enters, not where it is walked. When admins import or edit a rubric, reject a section that contains one of its own ancestors and cap the depth before the rubric is saved; every function that walks it can then assume a finite tree. When: a tree arrives from an import, an API or an admin screen (step 12). Verify (RAN, tips.mjs): the check refuses a rubric whose section contains the paper itself (CycleFound) and one nested 101 levels deep (TooDeep), and accepts the 4-level exam; without it, maxScore on the looping rubric throws RangeError.
  2. Remember each node's total when one leaf changes often. Keep the totals in a WeakMap keyed by node (dsa-recursion-memoization step 10 says why an object key needs edits that make new objects), and replace the path from the root to the edited leaf on each edit; unchanged subtrees keep their objects and their remembered totals. When: an editor recomputes a large tree on every keystroke. Verify (RAN, tips.mjs): on a rubric of 1,111 nodes, the first total computes all 1,111; after one criterion is edited, the new total computes 4 nodes and reads 27 remembered totals.
Source
  • TypeScript Handbook, Narrowing, section "Discriminated unions" (the row's second source): https://www.typescriptlang.org/docs/handbook/2/narrowing.html · fetched 2026-10-17. Teaching only; every key here is executed. "When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union, and can narrow out the members of the union." (steps 6, 8). Valid as of 2026-10-17.
  • Refactoring.Guru, Composite (not named by the row, so it keys nothing and supports teaching only): https://refactoring.guru/design-patterns/composite · fetched 2026-10-17. "Composite is a structural design pattern that lets you compose objects into tree structures and then work with these structures as if they were individual objects." (step 2); "Upon receiving a request, a container delegates the work to its sub-elements, processes intermediate results and then returns the final result to the client." (step 4); "Keep in mind that these operations can be declared in the component interface. This would violate the Interface Segregation Principle because the methods will be empty in the leaf class. However, the client will be able to treat all the elements equally, even when composing the tree." (step 10). Valid as of 2026-10-17.
  • The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), is a book and keys nothing.

Checked against the tools and docs as of 2026-10-17.