Definition
Command turns a request into an object of its own, so whoever runs it can be handed it, queue it, log it, or undo it. After Gamma et al., Design Patterns (1994), Command.
01Give an action a value
A button starts the work. It doesn’t have to own it.
You’re building a small layout editor. There is one selected card with a name and a horizontal position. At first, the Rename button reads an input and updates the name. A direct handler is clear, easy to follow, and enough for that first interaction.
// Each control changes the card itself, the moment it is used.
function onRenameClick(card: Card, input: string) {
card.name = input;
}
function onPaletteMove(card: Card, x: number) {
card.x = x;
} Now the command palette needs to move the selected card too, and the canvas shortcut should request Undo, or Redo with Shift. Later, you want to retain completed edits and show which request Undo will reverse. A handler that has already changed the card leaves nothing behind: no record of what was asked, and no way back.
Command represents a request as a value that another part of the program can execute. A rename carries the requested name and knows how to apply it. The button creates the request; the editor decides when to execute it and whether to keep it in history.
A function with captured arguments can be enough. An object helps when you also need a label or a common contract for several edit kinds. You already hand requests like these to code that runs them later:
| A familiar request | Who runs it, and when | What it carries |
|---|---|---|
| setTimeout(callback, ms) | The event loop, later. | Whatever the callback closed over. |
| VS Code · registerCommand(id, handler) | A keybinding, a menu or executeCommand. | A name and a handler; no undo promised. |
| React · dispatch({ type: "rename", name }) | The reducer, on the next render. | Plain data: the behavior stays in the reducer. |
02Follow one execution
Carry the new value. Remember the old one when you act.
This is the whole program the lab runs. A request has a label and an execute. Execute receives the card it should apply to and returns the changed card together with an inverse: the request that would put back what this execution replaced. Rename to Launch might return Rename to Release as its inverse; Move to 80 might return Move to 20.
The editor keeps the forward and inverse commands together. Undo runs the inverse and transfers that entry from Past to Future, where Redo (in the production version below) picks it up again. A successful new edit clears Future because you have chosen a different continuation. In the program, the toolbar’s rename dialog makes its request when it opens and hands it to the editor on OK; in between, the palette renames and moves the card.
type Card = { name: string; x: number };
type Change = { card: Card; inverse: Command };
// A request as a value: it knows how to apply itself to a card.
interface Command {
label: string;
// The changed card and the command that reverses it, or null: no change.
execute(card: Card): Change | null;
}
function rename(name: string): Command {
return {
label: 'Rename',
execute: (card) =>
card.name === name
? null
: { card: { ...card, name }, inverse: rename(card.name) },
};
}
function moveTo(x: number): Command {
return {
label: 'Move',
execute: (card) =>
card.x === x ? null : { card: { ...card, x }, inverse: moveTo(card.x) },
};
}
type Entry = { forward: Command; inverse: Command };
// The invoker: it runs every request and owns the card and its history.
class Editor {
card: Card;
past: Entry[] = [];
future: Entry[] = [];
constructor(card: Card) {
this.card = card;
}
execute(command: Command): boolean {
const change = command.execute(this.card);
if (!change) return false;
this.card = change.card;
this.past.push({ forward: command, inverse: change.inverse });
this.future = [];
return true;
}
undo(): boolean {
const entry = this.past.pop();
if (!entry) return false;
this.card = entry.inverse.execute(this.card)?.card ?? this.card;
this.future.push(entry);
return true;
}
}
const editor = new Editor({ name: 'Draft', x: 20 });
// The rename dialog opens, and the toolbar makes its request now.
const launch = rename('Launch');
// Before the dialog's OK, the palette renames and moves the card.
editor.execute(rename('Release'));
editor.execute(moveTo(80));
// OK: the editor runs the toolbar's request. Then Undo, once.
editor.execute(launch);
editor.undo();
const afterUndo = editor.card;
const past = editor.past.map((entry) => entry.forward.label);
const future = editor.future.map((entry) => entry.forward.label);
console.log(`${afterUndo.name} x=${afterUndo.x}`);
console.log(`past: ${past.join(', ')} | future: ${future.join(', ')}`); | Role | Here | Responsibility |
|---|---|---|
| Controls | Toolbar, palette, shortcuts | Read user intent and submit a command. Multiple controls can share the same action. |
| Command | Rename, Move | Validate the request; calculate a new card and an inverse using the current value. |
| Editor | The invoker | Own the current card and history. Invoke first, then commit a successful result. |
| Card | The receiver’s state | The name and position being edited, returned as a new value. |
The traditional roles are client, invoker, command, and receiver. Here the receiver’s state is a card value. The command returns its result to the editor, which installs it. A classic object version might call methods on a mutable receiver instead; the responsibility separation can stay the same.
What a production editor addsValidation, Redo, and history that stays honest
Names must contain at least one character; they are preserved exactly, including spaces and Unicode. Positions must be whole numbers from 0 through 100. Commands validate when executed. An invalid request fails before changing state. A valid request for the current value reports no change. Both leave the card, Past, and Future untouched. Empty Undo or Redo also reports no change.
Only successful new edits add an entry and clear Future. Undo and Redo move existing entries, and check that the entry’s command changed something before they move it. The card is private and frozen, and inspection returns independent copies, so inspecting history cannot rewrite it.
export type Card = Readonly<{ name: string; x: number }>;
export type Change = { card: Card; inverse: Command };
export interface Command {
readonly label: string;
// Validates when it runs, then calculates: null means nothing to change.
execute(card: Card): Change | null;
}
function checkName(name: string) {
if (name.length === 0) throw new Error('name must not be empty');
}
function checkPosition(x: number) {
if (!Number.isInteger(x) || x < 0 || x > 100)
throw new Error('position must be a whole number from 0 to 100');
}
export function rename(name: string): Command {
return {
label: 'Rename',
execute(card) {
checkName(name);
if (name === card.name) return null;
// The inverse is read from the card this execution receives.
return { card: { ...card, name }, inverse: rename(card.name) };
}
};
}
export function moveTo(x: number): Command {
return {
label: 'Move',
execute(card) {
checkPosition(x);
if (x === card.x) return null;
return { card: { ...card, x }, inverse: moveTo(card.x) };
}
};
}
type Entry = { forward: Command; inverse: Command };
export class Editor {
#card: Card;
#past: Entry[] = [];
#future: Entry[] = [];
constructor(card: Card = { name: 'Draft', x: 20 }) {
checkName(card.name);
checkPosition(card.x);
this.#card = Object.freeze({ ...card });
}
execute(command: Command): boolean {
const change = command.execute(this.#card); // throws before any change
if (!change) return false;
this.#card = Object.freeze({ ...change.card });
this.#past.push({ forward: command, inverse: change.inverse });
this.#future = [];
return true;
}
undo(): boolean {
const entry = this.#past.at(-1);
const change = entry?.inverse.execute(this.#card);
if (!entry || !change) return false;
this.#card = Object.freeze({ ...change.card });
this.#past.pop();
this.#future.push(entry);
return true;
}
redo(): boolean {
const entry = this.#future.at(-1);
const change = entry?.forward.execute(this.#card);
if (!entry || !change) return false;
this.#card = Object.freeze({ ...change.card });
this.#future.pop();
this.#past.push(entry);
return true;
}
snapshot() {
return {
card: { ...this.#card },
past: this.#past.map((entry) => entry.forward.label),
future: this.#future.map((entry) => entry.forward.label)
};
}
} All edits run synchronously through one editor. Commands must calculate their result without changing the input or performing external work. Arbitrary plugin code is not sandboxed by this interface, and arbitrary JSON needs validation before it becomes a typed request.
03Predict what Undo restores
The dialog’s request waits. What does Undo put back?
Experiment
One editor, a request that waits
// The dialog already shows the current name, so the request carries both.
function renameFrom(before: string, name: string): Command {
return {
label: 'Rename',
execute: (card) =>
card.name === name
? null
: { card: { ...card, name }, inverse: renameFrom(name, before) },
};
}
// Line 62, the dialog's request, becomes:
const launch = renameFrom(editor.card.name, 'Launch'); Reading the TypeScriptClosures, spreads, null, and private fields
rename(name) returns an object whose method closes over the requested name.
It does not read the old name yet. execute(card) reads that later and returns
a separate inverse object. Preparing or reusing a command therefore does not overwrite an
earlier history entry’s inverse.
{ ...card, name } makes a new card; the editor’s old card is not
changed. Change | null separates an edit from an unchanged request; the production
version throws for invalid ones. Its #past, #future and #card fields are private. Readonly is a type constraint, while Object.freeze protects the current card at runtime. Its two fields are scalar
values, so a shallow copy is enough here.
04Meet it in your language
A queued request: what it carries, and when it reads.
A queued job is a command: a request made now and run later by something else, here an
outbox that drains after the response is sent. The experiment’s rule has a twin. What a
request replaces is read when it runs; what it asks for is fixed when it is made. receiptJobLive breaks the second half: its closure captures the order object, not its values, so an edit to the cart before the outbox drains
changes a receipt the customer was already promised. receiptJob copies the values
when the job is made.
type Order = { id: string; total: number };
// A queued request: the outbox runs it later, after the response is sent.
type Job = () => string;
// Before: the job reads the order when it runs.
function receiptJobLive(order: Order): Job {
return () => `receipt ${order.id}: ${order.total}`;
}
// After: the job carries the values it was made with.
function receiptJob(order: Order): Job {
const { id, total } = order;
return () => `receipt ${id}: ${total}`;
}
const order: Order = { id: 'A-17', total: 40 };
const outbox: Job[] = [receiptJobLive(order), receiptJob(order)];
order.total = 55; // the customer edits the cart before the outbox drains
for (const job of outbox) console.log(job()); // receipt A-17: 55, then 40 The same goes for anything a job holds by reference: an array, a Date, a
request body. Copy what the job must keep, or make that data immutable.
05Decide when it helps
What do you need to do with the request?
If a button calls a short function immediately, I’d keep that function until another requirement appears. Commands earn their place when the request needs a life beyond that call site: it will be passed to another invoker, retained in history, described in the UI, or scheduled for later.
| Pressure | A simpler starting point | What Command adds |
|---|---|---|
| Two buttons perform the same edit | A shared function removes duplicate behavior. | A request value helps when the caller also needs to pass or retain the action. |
| Undo one local edit | Save a previous state (Memento) when snapshots are small and sufficient. | Retain the operation and the information needed to reverse that execution. |
| Add a third kind of edit | A small, closed reducer can handle a tagged action clearly. | A new implementation can supply behavior through the existing invoker contract. |
| Execute a request after restart | Persist a validated data record with a handler. | The request model can guide that design, but closures and trait objects need a separate serializable representation. |
Suppose you add a Rotate action. The card needs an angle, the new command needs its validation and inverse, and the controls need a way to select it. The editor’s history algorithm can stay the same because it calls the command contract without inspecting whether the action is Rename, Move, or Rotate.
The neighbouring ideas answer different questions. Memento saves state to restore it, which suits small snapshots or an inverse that would be awkward to write. A stack supplies last-in, first-out order for Past and Future, while Command supplies the executable request and its inverse. Strategy supplies a policy to an ongoing workflow; Command packages a request to be invoked. Both can use functions; the responsibility explains the distinction.
Queues, logs, and macros ask for moreUseful extensions with distinct contracts
A background job can represent intent as data: an operation name, target ID, arguments, and schema version. A worker resolves and executes it later. Persistence does not make retries safe; the operation still needs a duplicate-delivery policy.
A macro can group commands into one user action, but failure halfway through raises a new question: are earlier steps rolled back, retained as partial success, or performed in a transaction? Running a list in a loop does not supply atomicity.
A command requests an action. An event records something that happened. Logging attempted commands alone is not a trustworthy history of successful state changes.
The assumptions that keep these inverses validLifetime, grouping, and failure
Keep edits in order. An inverse assumes the surrounding history has been undone in reverse order. A remote edit or a direct mutation outside this editor can invalidate that assumption. Collaborative undo needs a conflict or transformation policy.
Choose what one Undo means. Dragging a card through 80 pointer events probably should not require 80 undos. One approach is a temporary drag preview followed by a single committed Move when the gesture ends. Merging commands must retain the first old position and the final new position.
Bound the lifetime. This editor retains history until it is discarded. A real editor needs limits on steps or retained memory. Closing a document should release its editor and UI listeners. Saving a document and marking a history position as saved are separate responsibilities.
Commit only after success. These commands calculate new values without I/O, and the editor changes its card and history only after they return. A command that sends a request and then throws may already have changed the world. Supporting asynchronous operations requires pending-state, ordering, cancellation, and failure rules.
Editing APIs draw the line in other placesQt, ProseMirror and VS Code
Qt’s QUndoCommand defines undo and
redo behavior, while QUndoStack owns the history and drops an undone branch when
a new command is pushed. This lesson instead returns an inverse value at execution.
A ProseMirror command receives state and optional dispatch, so a caller can ask whether it applies without dispatching its effect. That applicability contract does not require a per-command inverse.
VS Code’s registerCommand connects an ID to a handler that keybindings, menus and executeCommand can invoke.
Treat it as a counterexample to “a command must have undo”: the registration API binds a handler
without requiring an inverse.
06Make the call
Explain what the button hands off.
Before you open the answer below, say it in your own words: what does a button hand off, what must a reversible request remember, and when does it find that out?
One way to say itWhat the request carries, and when it reads
“The controls create an edit request. The editor executes it and keeps the inverse from that execution, so another control can reverse the last completed edit.” A request carries what it asks for from the moment it is made; what it replaces is read when it runs, from the state the editor hands it, so a request prepared early, queued or reused still undoes to the right place. Reading it at preparation is the stale-inverse bug: Undo restores a value from before other edits and silently erases them. Only a successful new edit adds a step and clears Redo; an invalid or unchanged one leaves history alone. And when a button calls one function at once, with no history, no queue and no second invoker, keep the function: Command earns its extra names when the request needs a life beyond the call.
Practice in the lab
A home lighting app: the wall panel, the phone app and an evening schedule all send requests (set a light, dim it, run a scene) to one controller, which runs them and keeps Undo and Redo history for the whole home. Each level starts from the previous level’s finished code, so you can begin at any of them.
- COMMAND-101 (easy) Undo after the dimmer brings the light back too bright
- COMMAND-102 (medium) A scene takes three Undos and half-runs when it fails Pro
- COMMAND-103 (hard) Review: one Undo per turn of the dimmer wheel Pro