← Design patterns
Composition Behavior around an existing interface

Decorator

Same interface, another layer.

A command-line diagnostic tool renders a few lines. Terminal output and a saved report need different combinations of numbering and framing, while the command still calls render(). Wrap the same TextBlock operation, then follow what each layer receives and returns.

TypeScriptGoRust The same wrappers in each language.

Definition

A decorator attaches a responsibility to an object at run time by wrapping it in another object with the same interface, a flexible alternative to subclassing for extending behavior.

After Gamma et al., Design Patterns (1994), Decorator

01Keep the caller’s operation

The command keeps calling render().

A command-line diagnostics tool prints a few lines. The terminal shows them numbered; a saved report shows them numbered inside a frame. At first the command formats its own output, with a flag for each layer.

display.ts
// The command formats its own output, with one flag per layer.
// (A fragment: frameLines draws the border.)
function display(lines: string[], options: { numbers: boolean; frame: boolean }) {
  let shown = [...lines];
  if (options.numbers) shown = shown.map((line, i) => `${i + 1}: ${line}`);
  if (options.frame) shown = frameLines(shown);
  console.log(shown.join('\n'));
}

const lines = ['check: links', 'broken: 2'];
display(lines, { numbers: true, frame: false }); // the terminal
display(lines, { numbers: true, frame: true }); // the saved report

That is clear while there are two outputs and two layers: one function, read top to bottom. Then requirements arrive one at a time. The CI log wants a heading above the frame, the tests want the plain lines, and another tool that imports the command brings a layer of its own. Each is another flag and another branch inside the command, every caller passes through all of them, and a command whose job is to print now owns every way of formatting.

The diagnostics command accepts a TextBlock and renders its lines. A wrapper receives a TextBlock, keeps that collaborator, and also offers TextBlock. The command can use the wrapped value wherever it used the source. Numbers adds a prefix to every returned line. Frame adds a border around the lines it gets.

A Decorator adds behavior around an object while preserving the interface through which its caller uses it. Each wrapper delegates during the call and adds its own responsibility to the result. Because the wrapper offers the same interface, another wrapper can use it as its source.

02Add one behavior per wrapper

Frame and Numbers both offer TextBlock.

Each wrapper calls its inner block once per render, changes the lines it gets back, and offers the same interface. Frame measures the lines returned by its inner block, then adds matching edges and padding. This is the whole program the lab runs: the source, the two wrappers, the command, and the two stacks setup builds, the terminal’s and the report’s.

blocks.ts
interface TextBlock {
  render(): string[];
}

// The source: the diagnostic lines themselves.
function plainText(lines: string[]): TextBlock {
  return { render: () => [...lines] };
}

// Numbers: the same interface, plus a prefix on every line it gets back.
function withNumbers(inner: TextBlock): TextBlock {
  return {
    render() {
      const lines = inner.render();
      return lines.map((line, i) => `${i + 1}: ${line}`);
    },
  };
}

// Frame: the same interface, plus a border around the lines it gets back.
function withFrame(inner: TextBlock): TextBlock {
  return {
    render() {
      const lines = inner.render();
      const width = Math.max(...lines.map((line) => line.length));
      const edge = `+${'-'.repeat(width + 2)}+`;
      return [edge, ...lines.map((line) => `| ${line.padEnd(width)} |`), edge];
    },
  };
}

// The command renders any TextBlock and never asks which layers it has.
function display(name: string, block: TextBlock) {
  const shown = block.render();
  console.log(`${name}:\n${shown.join('\n')}`);
}

const source = plainText(['check: links', 'broken: 2']);
const terminal = withNumbers(source);
const report = withFrame(withNumbers(source));
display('terminal', terminal);
display('report', report);
Who owns what
RoleResponsibilityWhat it handles
CallerAsks for the block.The command handles the returned lines. It does not choose how numbering or framing is implemented.
WrapperAdds one responsibility.Numbers prefixes each line. Frame adds borders around the returned lines. Each wrapper still offers render.
SourceReturns the lines.The plain block owns the source lines and returns them when asked.
SetupBuilds the stack.Chooses which wrappers each output gets, and in what order, then hands the command a TextBlock.
What a production decorator addsA checked source, empty blocks, named stacks

The same wrappers, with a contract. The source accepts at most four printable ASCII lines, each no longer than 32 characters, and rejects anything else by throwing UnsupportedLines. It snapshots the input, so later changes to the caller’s lines do not change the block, and every render returns fresh arrays, so a caller that edits a result cannot change the next one. An empty list remains empty: Frame draws no border around nothing.

Setup names each stack it supports, so the order of every output is written down once and tested. The stack names are a union type, so a misspelled name does not compile and the switch covers every stack.

blocks.ts
export interface TextBlock {
	render(): string[];
}

export const maxLines = 4;
export const maxLineLength = 32;
const message = `use at most ${maxLines} lines of ${maxLineLength} printable ASCII characters`;

export class UnsupportedLines extends Error {
	constructor() {
		super(message);
		this.name = 'UnsupportedLines';
	}
}

export function plainText(lines: readonly string[]): TextBlock {
	const printable = (line: string) =>
		line.length <= maxLineLength && /^[\x20-\x7e]*$/.test(line);
	if (lines.length > maxLines || !lines.every(printable)) throw new UnsupportedLines();
	const snapshot = [...lines]; // later changes to the caller's array don't leak in
	return { render: () => [...snapshot] };
}

export function withNumbers(inner: TextBlock): TextBlock {
	return {
		render() {
			const lines = inner.render();
			return lines.map((line, i) => `${i + 1}: ${line}`);
		}
	};
}

export function withFrame(inner: TextBlock): TextBlock {
	return {
		render() {
			const lines = inner.render();
			if (lines.length === 0) return []; // no output stays no output
			const width = Math.max(...lines.map((line) => line.length));
			const edge = `+${'-'.repeat(width + 2)}+`;
			return [edge, ...lines.map((line) => `| ${line.padEnd(width)} |`), edge];
		}
	};
}

// Setup names each stack it supports; the command only ever sees a TextBlock.
export const stackNames = [
	'bare',
	'numbers',
	'frame',
	'frame-numbers',
	'numbers-frame',
	'numbers-numbers'
] as const;
export type Stack = (typeof stackNames)[number];

export function decorate(source: TextBlock, stack: Stack): TextBlock {
	switch (stack) {
		case 'bare':
			return source;
		case 'numbers':
			return withNumbers(source);
		case 'frame':
			return withFrame(source);
		case 'frame-numbers':
			return withFrame(withNumbers(source));
		case 'numbers-frame':
			return withNumbers(withFrame(source));
		case 'numbers-numbers':
			return withNumbers(withNumbers(source));
	}
}

03Predict the swapped stack

Swap the two wrappers. What comes back?

Experiment

One source, two stacks

blocks.ts
// Line 40 swaps the two wrappers:
const report = withNumbers(withFrame(source));
Suppose line 40 swaps the report’s two wrappers, as above, so withNumbers is outside and withFrame inside. What is the report’s first line?
Reading the TypeScriptStructural interfaces and a captured collaborator

Both wrapper functions return an object with render. That satisfies TextBlock without a base class. The closure retains inner and delegates to it only when the caller renders.

Numbers maps the inner lines without changing the source. Errors from the inner block propagate; wrappers do not silently replace a failed source. In the small program, Frame never sees an empty block: Math.max() of nothing is -Infinity, and repeat would throw, which is why the production Frame returns [] first.

04Meet it in your language

You already wrap things this way.

Wrappers around fetch are decorators: withAuth(fetch, () => token) returns a function with fetch’s own shape, so a caller cannot tell it from the original. The bug readers meet is a logging wrapper that reads the body to log it. A response body can be read once, so the caller’s response.json() then throws a TypeError (Node 22: “Body is unusable: Body has already been read”). The wrapper changed behavior it was meant to leave alone. The fix logs a copy, response.clone(), and returns the original untouched.

fetch.ts
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;

// Adds the current token to every request, read when the request is made.
function withAuth(next: Fetch, token: () => string): Fetch {
  return (input, init) => {
    const headers = new Headers(init?.headers);
    headers.set('authorization', `Bearer ${token()}`);
    return next(input, { ...init, headers });
  };
}

// Before: logs the body, and hands back a response it has already read.
function withLoggingBefore(next: Fetch): Fetch {
  return async (input, init) => {
    const response = await next(input, init);
    console.log(input, response.status, await response.text());
    return response;
  };
}

// After: logs a copy, so the caller's response is untouched.
function withLogging(next: Fetch): Fetch {
  return async (input, init) => {
    const response = await next(input, init);
    console.log(input, response.status, await response.clone().text());
    return response;
  };
}

let token = 'signed-out';
const api = withLogging(withAuth(fetch, () => token));

Order is setup’s choice here too, and so is timing: withAuth reads the token when each request is made, not when the wrapper is built, so the request after a refresh carries the new token.

05Compare the simpler choice

Use layers when the responsibilities vary independently.

Application setup assembles the chosen wrappers and passes the result to the command. The command depends on TextBlock; setup owns layer selection and order. Give the code a plausible change and follow what has to change with it.

What changes when another requirement arrives?
The changeA direct formatterWrappers stacked at setup
A new layer arrives (a heading line)Another flag and branch in the formatter; every caller’s options grow.A new wrapper. The command and the other layers do not change.
Two outputs want the layers in different ordersAn order parameter, or a second formatter.Setup builds a different stack, and each stack is named and tested.
One caller needs one stable formatA direct formatter keeps setup and control flow local.Two objects and a setup step for one fixed output.
Layers depend on shared hidden state or must change atomicallyA named combined operation can make the contract easier to maintain.Each wrapper sees only its inner block’s result, so the shared state leaks between them.

A direct formatter is easier for one caller and one stable output. Independent wrappers earn their cost when combinations recur. Their small interfaces still require clear ownership, failure, and ordering rules.

The interface remains compatible; observable behavior can change. Numbering changes each line, while framing changes the surrounding structure, and the two orders are different, valid outputs, so setup must choose and name the one it means. Keep the interface small: the operation every layer offers, and nothing else. A wrapper adds one responsibility and keeps the rest of its inner block’s contract, its results and its failures; methods it does not offer, such as close, flush or seek, are not part of the stack, just because the source has them.

Who closes a wrapped resource?Decide ownership separately from delegation

Constructing a wrapper does not make a fresh source: the wrappers retain the same block and call it when render runs. A wrapper can own its inner resource, borrow it, or share it under a separate owner. State whether closing the wrapper closes the resource and whether another caller may keep using it. A same-shaped render method answers neither question.

In TypeScript nothing in the type says so; write it down, and give the stack one owner that releases it. Here the source and wrappers hold only memory, so no close protocol is supplied.

Its neighbours: Adapter translates an existing interface into the one a caller needs; Decorator starts with a compatible interface and adds behavior around it. Proxy concerns access to a represented object: Proxy and Decorator can have very similar code, and a proxy may refuse, defer, or substitute an operation when its contract allows it, while Numbers and Frame each call their inner render every time. Strategy supplies a chosen policy to a consumer; Facade presents a convenient entry point to a subsystem; and Composition over inheritance gives a broader view of supplying collaborators.

06Make the call

Name the added behavior and the preserved contract.

An HTTP client wraps its send function twice: one layer retries a failed request up to three times; the other adds the current auth token, read when the layer runs. Tokens expire, and the attempt after a 401 should carry a refreshed one. Which stack does that?
A one-off script prints a single summary, always numbered and framed. No other caller formats anything, and the format never changes. What should it use?

Before you open the answer below, explain the report’s stack without saying “Decorator”: the caller still uses this interface; this layer adds this behavior; it delegates here; this result comes back. Then swap two layers and name what would change.

One way to say itThe interface kept, the behavior added, the order chosen

The command still calls render on a TextBlock and prints whatever comes back. Frame, the outer layer, asks Numbers for its lines and draws a border around them; Numbers asks the source and prefixes each line; the source returns the two diagnostic lines. Each layer adds one thing to what its inner layer returns, so swapping them changes what each one receives: Numbers outside would number the border too. That is why setup, not the command, chooses and names the order. Use a decorator when surrounding behavior recurs, varies independently, and belongs at a stable interface; keep a direct call when one place needs one stable behavior. And a wrapper that changes what failure means, swallowing an error or inventing a value, changes the contract for every caller below it, so give it only to the caller that asked.

Practice in the lab

A greenhouse’s bench 3 has one temperature probe. A dashboard shows its reading and a frost alert pages the grower below 2.0 °C; both take a Sensor and call read(), just as the lesson’s command takes a TextBlock and calls render(). Each level starts from the previous level’s finished code, so you can begin at any of them.

  • DECORATOR-101 (easy) The frost alert pages on a probe that reads low
  • DECORATOR-102 (medium) The partner greenhouse wants Fahrenheit on its dashboard Pro
  • DECORATOR-103 (hard) Review: the frost alert stopped hearing about dropouts Pro