← Design patterns
Behavior Actions and their execution

Command

Give an action a value.

Your editor has a Rename button, then a command palette that moves the selected card. The toolbar submits Rename; the palette submits Move; the canvas shortcut requests Undo or Redo. Where should those actions live, and what must a reversible edit remember?

TypeScriptGoRust The same editor in each language.

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.

controls.ts
// 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:

Requests you already hand off
A familiar requestWho runs it, and whenWhat 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.

editor.ts
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(', ')}`);
Who owns what
RoleHereResponsibility
ControlsToolbar, palette, shortcutsRead user intent and submit a command. Multiple controls can share the same action.
CommandRename, MoveValidate the request; calculate a new card and an inverse using the current value.
EditorThe invokerOwn the current card and history. Invoke first, then commit a successful result.
CardThe receiver’s stateThe 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.

editor.ts
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

editor.ts
// 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');
Suppose the dialog made its request with the renameFrom above, which reads the current name when the dialog opens, and line 62 changed as shown. After the program’s one Undo, what does the card hold?
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.

outbox.ts
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.

What do you need to do with the request?
PressureA simpler starting pointWhat Command adds
Two buttons perform the same editA shared function removes duplicate behavior.A request value helps when the caller also needs to pass or retain the action.
Undo one local editSave 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 editA small, closed reducer can handle a tagged action clearly.A new implementation can supply behavior through the existing invoker contract.
Execute a request after restartPersist 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.

A settings page has one Save button. It calls save(settings) at once, nothing else calls it, and there is no undo. What should the button hold?
After undoing a Move, the user opens the rename dialog and confirms the name the card already has. What should happen to the pending Redo?
Somewhere else: a spreadsheet’s Fill Down writes one value into 40 cells, some of which held values. One Undo must reverse it. What should its history entry hold?

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