← Back to PatternPip

COMMAND / QUICK REFERENCE

Package a request.
Give it a common lifecycle.

The invoker works with Command. The concrete command knows the receiver and arguments. Undo is an explicit capability in this example, not a guarantee of the pattern.

What becomes an object?
A request: the operation, its receiver and its arguments travel together.
What does the invoker know?
Only the Command contract. It can execute and retain requests without knowing how the receiver works.
Where does undo come from?
An explicit undo operation and enough saved state. Command does not automatically make every action reversible.

Who owns what?

Client · editor controls
Creates a fresh command with the receiver and arguments, then passes it to history.
Invoker · CommandHistory
Executes Command objects and manages undo/redo stacks without branching on edit types.
Command · InsertText / ReplaceText
Stores the request, delegates to the document and remembers the previous text for undo.
Receiver · TextDocument
Owns the actual text and performs writes. It does not manage undo stacks.

Undo/redo rules in this editor

  • New edit: capture the previous text, execute, then push the command to Undo and clear Redo.
  • Undo: restore the saved text and move the command from Undo to Redo.
  • Redo: execute the same request and move it from Redo to Undo.
  • No change or empty history: no new entry and no redo branch lost.
  • One request, one object: create a fresh command for each new edit so earlier undo state is not overwritten.

This example assumes all document writes go through one local history. Concurrent or external writes can invalidate snapshots. Failures must occur before mutation or provide their own rollback; the invoker cannot make arbitrary side effects transactional.

The boundaries in code

Command contract · TypeScript
export interface Command {
  readonly label: string;
  execute(): boolean; // true only when the receiver changed
  undo(): void;
}
Concrete requests · TypeScript
export class InsertText implements Command {
  readonly label = "Insert text";
  private before: string | null = null;
  private document: TextDocument;
  private text: string;
  constructor(document: TextDocument, text: string) {
    this.document = document;
    this.text = text;
  }
  execute() {
    if (!this.text) return false;
    this.before = this.document.text;
    this.document.write(this.before + this.text);
    return true;
  }
  undo() {
    if (this.before === null) throw new Error("Execute before undo");
    this.document.write(this.before);
  }
}
export class ReplaceText implements Command {
  readonly label = "Replace text";
  private before: string | null = null;
  private document: TextDocument;
  private text: string;
  constructor(document: TextDocument, text: string) {
    this.document = document;
    this.text = text;
  }
  execute() {
    if (this.text === this.document.text) return false;
    this.before = this.document.text;
    this.document.write(this.text);
    return true;
  }
  undo() {
    if (this.before === null) throw new Error("Execute before undo");
    this.document.write(this.before);
  }
}
The invoker · TypeScript
export class CommandHistory {
  private done: Command[] = [];
  private undone: Command[] = [];
  run(command: Command) {
    if (this.done.includes(command) || this.undone.includes(command))
      throw new Error("Create a fresh command for each new request");
    if (!command.execute()) return false;
    this.done.push(command);
    this.undone = []; // a new successful edit starts a different future
    return true;
  }
  undo() {
    const command = this.done.at(-1);
    if (!command) return false;
    command.undo();
    this.done.pop();
    this.undone.push(command);
    return true;
  }
  redo() {
    const command = this.undone.at(-1);
    if (!command || !command.execute()) return false;
    this.undone.pop();
    this.done.push(command);
    return true;
  }
  snapshot() {
    return Object.freeze({
      done: Object.freeze(this.done.map((command) => command.label)),
      undone: Object.freeze(this.undone.map((command) => command.label)),
    });
  }
}
The receiver · TypeScript
export class TextDocument {
  private value: string;
  constructor(initial = "Hello") {
    this.value = initial;
  }
  get text() {
    return this.value;
  }
  write(text: string) {
    this.value = text;
  }
}

Complete Command example

The running TypeScript editor model and its standalone Kotlin equivalent.

Running TypeScript model, history and direct comparison.

Download editor.ts
// contract:start
export interface Command {
  readonly label: string;
  execute(): boolean; // true only when the receiver changed
  undo(): void;
}
// contract:end
// receiver:start
export class TextDocument {
  private value: string;
  constructor(initial = "Hello") {
    this.value = initial;
  }
  get text() {
    return this.value;
  }
  write(text: string) {
    this.value = text;
  }
}
// receiver:end
// commands:start
export class InsertText implements Command {
  readonly label = "Insert text";
  private before: string | null = null;
  private document: TextDocument;
  private text: string;
  constructor(document: TextDocument, text: string) {
    this.document = document;
    this.text = text;
  }
  execute() {
    if (!this.text) return false;
    this.before = this.document.text;
    this.document.write(this.before + this.text);
    return true;
  }
  undo() {
    if (this.before === null) throw new Error("Execute before undo");
    this.document.write(this.before);
  }
}
export class ReplaceText implements Command {
  readonly label = "Replace text";
  private before: string | null = null;
  private document: TextDocument;
  private text: string;
  constructor(document: TextDocument, text: string) {
    this.document = document;
    this.text = text;
  }
  execute() {
    if (this.text === this.document.text) return false;
    this.before = this.document.text;
    this.document.write(this.text);
    return true;
  }
  undo() {
    if (this.before === null) throw new Error("Execute before undo");
    this.document.write(this.before);
  }
}
// commands:end
// history:start
export class CommandHistory {
  private done: Command[] = [];
  private undone: Command[] = [];
  run(command: Command) {
    if (this.done.includes(command) || this.undone.includes(command))
      throw new Error("Create a fresh command for each new request");
    if (!command.execute()) return false;
    this.done.push(command);
    this.undone = []; // a new successful edit starts a different future
    return true;
  }
  undo() {
    const command = this.done.at(-1);
    if (!command) return false;
    command.undo();
    this.done.pop();
    this.undone.push(command);
    return true;
  }
  redo() {
    const command = this.undone.at(-1);
    if (!command || !command.execute()) return false;
    this.undone.pop();
    this.done.push(command);
    return true;
  }
  snapshot() {
    return Object.freeze({
      done: Object.freeze(this.done.map((command) => command.label)),
      undone: Object.freeze(this.undone.map((command) => command.label)),
    });
  }
}
// history:end
// before:start
export class DirectEditor {
  private done: string[] = [];
  private undone: string[] = [];
  readonly document: TextDocument;
  constructor(document: TextDocument) {
    this.document = document;
  }
  insert(text: string) {
    if (!text) return false;
    this.done.push(this.document.text);
    this.document.write(this.document.text + text);
    this.undone = [];
    return true;
  }
  replace(text: string) {
    if (text === this.document.text) return false;
    this.done.push(this.document.text);
    this.document.write(text);
    this.undone = [];
    return true;
  }
  undo() {
    if (!this.done.length) return false;
    this.undone.push(this.document.text);
    this.document.write(this.done.pop()!);
    return true;
  }
  redo() {
    if (!this.undone.length) return false;
    this.done.push(this.document.text);
    this.document.write(this.undone.pop()!);
    return true;
  }
}
// before:end
export type EditorSnapshot = Readonly<{
  text: string;
  done: readonly string[];
  undone: readonly string[];
}>;
export type EditorAction = "insert" | "replace" | "undo" | "redo";
export type EditorRun = Readonly<{
  action: EditorAction;
  label: string;
  changed: boolean;
  before: EditorSnapshot;
  after: EditorSnapshot;
}>;
export class EditorSession {
  readonly document = new TextDocument();
  readonly history = new CommandHistory();
  snapshot(): EditorSnapshot {
    return Object.freeze({
      text: this.document.text,
      ...this.history.snapshot(),
    });
  }
  act(action: EditorAction, text = ""): EditorRun {
    const before = this.snapshot();
    const changed =
      action === "undo"
        ? this.history.undo()
        : action === "redo"
          ? this.history.redo()
          : this.history.run(
              action === "insert"
                ? new InsertText(this.document, text)
                : new ReplaceText(this.document, text),
            );
    const label =
      action === "undo"
        ? (before.done.at(-1) ?? "No command")
        : action === "redo"
          ? (before.undone.at(-1) ?? "No command")
          : action === "insert"
            ? "Insert text"
            : "Replace text";
    return Object.freeze({
      action,
      label,
      changed,
      before,
      after: this.snapshot(),
    });
  }
}

Kotlin compilation is unverified. The editor uses local synchronous writes and whole-text snapshots.

kotlinc Editor.kt -include-runtime -d editor.jar
java -jar editor.jar

TypeScript excerpts come from the running model. Kotlin is equivalent instructional source; compilation is unverified.

When to use it—and when to stop.

Useful when

Requests need to be passed around, triggered by different controls or managed by a shared history. The same contract can support other invokers such as a queue, with explicit execution policies.

Costs

More objects and bookkeeping. Whole-text snapshots use memory proportional to the document for every edit. A function or a small snapshot history may be simpler; large editors often use deltas or persistent data structures.

  • Command versus Strategy: Command represents a particular request; Strategy represents an interchangeable way to perform a job.
  • Command versus Memento: Command owns behavior; Memento captures state. They can work together. This example simply stores a string snapshot.
  • Undo is not universal: external effects may need compensating actions, permission checks or no undo at all.
  • A queue is not reliability: retries, persistence and idempotency need their own design. They are not implemented here.