Command

Advanced · 12 min read · ▶ live playground · ✦ checkpoint

A text editor's Insert button calls editor.insert() and its Delete button calls editor.delete(). Then undo ships: inserting can be reversed from the button's arguments, but deleted text is already gone, and every new button adds another case to a central undo() method.

The Command pattern turns a request into an object that contains the action and the information needed to perform it. Requests can then be queued, logged, retried, or undone, at the cost of one object per action and more lifecycle state around history.

Before: controls invoke the editor directly

Direct calls are a good starting point. The trouble begins when the toolbar must remember enough data to reverse methods it does not own:

type LastAction =
  | { kind: "insert"; text: string }
  | { kind: "delete"; count: number };
 
class TextEditor {
  private text = "";
 
  public insertAtEnd(text: string): void {
    this.text += text;
  }
 
  public deleteFromEnd(count: number): void {
    const end = Math.max(0, this.text.length - count);
    this.text = this.text.slice(0, end);
  }
}
 
class Toolbar {
  private lastAction: LastAction | null = null;
 
  public constructor(private editor: TextEditor) {}
 
  public clickInsert(text: string): void {
    this.editor.insertAtEnd(text);
    this.lastAction = { kind: "insert", text };
  }
 
  public clickDelete(count: number): void {
    this.editor.deleteFromEnd(count);
    this.lastAction = { kind: "delete", count };
  }
 
  public undo(): void {
    if (this.lastAction?.kind === "insert") {
      this.editor.deleteFromEnd(this.lastAction.text.length);
    }
 
    if (this.lastAction?.kind === "delete") {
      // Bug: the toolbar knows the count, but the deleted text is gone.
    }
  }
}

The toolbar now understands the inverse of editor operations. It remembers only one action, and the delete action threw away the exact data undo needs. Adding Replace, Paste, or Format means another control method, another history shape, and another branch in the central reversal logic.

The receiver should still own text mutation. What changes is where each request and its inverse are packaged.

After: each request knows how to reverse itself

A command is an object that packages a request behind operations such as execute() and undo(). The receiver is the object that performs the domain work; TextEditor is the receiver. The invoker is the object that triggers commands and manages their timing or history; CommandManager is the invoker.

class diagram

Command objects connect history to the text editor

Command objects connect history to the text editor
  1. 1Command gives every request the same forward and reverse operations.
  2. 2TextEditor is the receiver; it remains the only object that mutates the document.
  3. 3InsertTextCommand implements Command and captures the text and position.
  4. 4Executing and undoing the insert both delegate document changes to TextEditor.
  5. 5DeleteTextCommand follows the same contract and remembers what execution removed.
  6. 6The delete command uses the receiver while retaining enough data for its inverse.
  7. 7CommandManager stores interface references, so its history works for either request.

Both concrete commands hold a receiver and their arguments. DeleteTextCommand also records the result of its first operation because an inverse cannot reconstruct deleted text from a count alone.

interface Command {
  execute(): void;
  undo(): void;
}
 
class TextEditor {
  private text = "";
 
  public insert(position: number, value: string): void {
    this.text =
      this.text.slice(0, position) + value + this.text.slice(position);
  }
 
  public remove(position: number, count: number): string {
    const removed = this.text.slice(position, position + count);
    this.text =
      this.text.slice(0, position) + this.text.slice(position + count);
    return removed;
  }
 
  public get value(): string {
    return this.text;
  }
}
 
class InsertTextCommand implements Command {
  public constructor(
    private editor: TextEditor,
    private position: number,
    private text: string,
  ) {}
 
  public execute(): void {
    this.editor.insert(this.position, this.text);
  }
 
  public undo(): void {
    this.editor.remove(this.position, this.text.length);
  }
}
 
class DeleteTextCommand implements Command {
  private deletedText = "";
 
  public constructor(
    private editor: TextEditor,
    private position: number,
    private count: number,
  ) {}
 
  public execute(): void {
    this.deletedText = this.editor.remove(this.position, this.count);
  }
 
  public undo(): void {
    this.editor.insert(this.position, this.deletedText);
  }
}
 
class CommandManager {
  private history: Command[] = [];
  private redoStack: Command[] = [];
 
  public run(command: Command): void {
    command.execute();
    this.history.push(command);
    this.redoStack = [];
  }
 
  public undo(): void {
    const command = this.history.pop();
    if (command === undefined) return;
 
    command.undo();
    this.redoStack.push(command);
  }
 
  public redo(): void {
    const command = this.redoStack.pop();
    if (command === undefined) return;
 
    command.execute();
    this.history.push(command);
  }
}
 
const editor = new TextEditor();
const manager = new CommandManager();
 
manager.run(new InsertTextCommand(editor, 0, "Design"));
manager.run(new InsertTextCommand(editor, 6, " patterns"));
console.log(`after inserts: ${editor.value}`);
 
manager.run(new DeleteTextCommand(editor, 6, 9));
console.log(`after delete: ${editor.value}`);
 
manager.undo();
console.log(`undo delete: ${editor.value}`);
 
manager.undo();
console.log(`undo insert: ${editor.value}`);
 
manager.redo();
console.log(`redo insert: ${editor.value}`);

after inserts: Design patterns after delete: Design undo delete: Design patterns undo insert: Design redo insert: Design patterns

The delete command captures " patterns" during execute(). Its undo() inserts those exact characters at the original position, so the first undo restores the document. The second undo reverses the earlier insert, and redo executes that same command again.

Running a new command clears redoStack. After undo, history has branched: keeping commands from the abandoned branch would let redo apply an action to a document state it was not created for.

What storing a request buys you

Once a request conforms to Command, the invoker can treat actions as data:

  • A queue can hold commands until a worker is ready.
  • An audit trail can log a command's name and safe arguments.
  • A grouped command can execute several commands in order and undo them in reverse order.
  • A retry policy can rerun commands whose effects are safe to repeat.

Those capabilities are not free. Commands sent to another service need arguments that can be encoded for transport. Retrying an operation can duplicate side effects unless it is designed to be idempotent, meaning repeated execution has the same intended effect as one execution. Undo can fail too: restoring a local document is different from reversing an email that has already reached someone.

The interface should promise only the lifecycle the domain can honor. If an action cannot be undone, do not provide a fake undo() that silently does nothing. Separate undoable and non-undoable commands, or let history reject the latter explicitly.

When NOT to use it

For a single direct call with no queue, history, logging, retry, or scheduling requirement, a command class is ceremony. Calling editor.save() is easier to trace than constructing SaveCommand, passing it to an invoker, and opening another file to find one delegated line.

A closure is a function that retains the values around it, and it can be a lightweight command when a request only needs deferred execution. If undo matters, a small pair of closures for forward and reverse work may still be enough. Named classes become valuable when requests need rich state, several policies inspect them, or their lifecycle deserves focused tests.

Large histories also retain command arguments and receiver references. A long editing session can consume memory, while commands that store mutable objects may replay against values that have changed. Snapshot only the data needed for reversal and decide when old history expires.

Command trades call-site directness for a manipulable request lifecycle. Use it when the system genuinely needs to treat calls as objects, and define execution, failure, undo, and redo semantics before the history grows.

Checkpoint

Answer all three to mark this lesson complete

Command makes an action's lifecycle explicit. Next, State moves changing behavior into objects that represent the context's current condition.

+50 XP on completion