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.
// 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.
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); | Role | Responsibility | What it handles |
|---|---|---|
| Caller | Asks for the block. | The command handles the returned lines. It does not choose how numbering or framing is implemented. |
| Wrapper | Adds one responsibility. | Numbers prefixes each line. Frame adds borders around the returned lines. Each wrapper still offers render. |
| Source | Returns the lines. | The plain block owns the source lines and returns them when asked. |
| Setup | Builds 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.
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
// Line 40 swaps the two wrappers:
const report = withNumbers(withFrame(source)); 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.
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.
| The change | A direct formatter | Wrappers 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 orders | An order parameter, or a second formatter. | Setup builds a different stack, and each stack is named and tested. |
| One caller needs one stable format | A 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 atomically | A 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.
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