StackPractices
intermediate By Mathias Paulenko

Command Pattern with Undo/Redo in TypeScript

Implement the Command pattern to encapsulate requests as objects, enabling undo/redo operations, request queuing, and operation logging

Topics: design

The Command pattern turns a request into a stand-alone object containing all information about the request. This decoupling allows you to parameterize methods with different requests, delay or queue execution, and implement undo/redo operations — essential for interactive applications like editors, drawing tools, and form builders.

When to Use This

  • You need undo/redo functionality in a user interface
  • Operations must be queued, logged, or executed remotely
  • The invoker should not know which receiver handles a request

Problem

A text editor directly calls methods on a document object. Adding undo requires exposing internal state, and adding macros requires duplicating logic across the UI layer.

Solution

// commands/Command.ts
interface Command {
  execute(): void;
  undo(): void;
  getName(): string;
}

// Receiver
class TextDocument {
  private content = '';
  private history: string[] = [''];

  insert(text: string, position: number): void {
    this.content = this.content.slice(0, position) + text + this.content.slice(position);
    this.saveState();
  }

  delete(position: number, length: number): string {
    const removed = this.content.slice(position, position + length);
    this.content = this.content.slice(0, position) + this.content.slice(position + length);
    this.saveState();
    return removed;
  }

  getContent(): string {
    return this.content;
  }

  private saveState(): void {
    this.history.push(this.content);
  }

  restoreState(index: number): void {
    this.content = this.history[index] ?? this.content;
  }
}

// Concrete Commands
class InsertCommand implements Command {
  private previousLength: number;

  constructor(
    private document: TextDocument,
    private text: string,
    private position: number
  ) {
    this.previousLength = document.getContent().length;
  }

  execute(): void {
    this.document.insert(this.text, this.position);
  }

  undo(): void {
    this.document.delete(this.position, this.text.length);
  }

  getName(): string {
    return `Insert "${this.text}"`;
  }
}

class DeleteCommand implements Command {
  private deletedText: string = '';

  constructor(
    private document: TextDocument,
    private position: number,
    private length: number
  ) {}

  execute(): void {
    this.deletedText = this.document.delete(this.position, this.length);
  }

  undo(): void {
    this.document.insert(this.deletedText, this.position);
  }

  getName(): string {
    return `Delete ${this.length} chars`;
  }
}

// Invoker
class CommandHistory {
  private history: Command[] = [];
  private currentIndex = -1;

  execute(command: Command): void {
    command.execute();
    
    // Remove any redo commands
    this.history = this.history.slice(0, this.currentIndex + 1);
    this.history.push(command);
    this.currentIndex++;
  }

  undo(): void {
    if (this.currentIndex < 0) return;
    this.history[this.currentIndex].undo();
    this.currentIndex--;
  }

  redo(): void {
    if (this.currentIndex >= this.history.length - 1) return;
    this.currentIndex++;
    this.history[this.currentIndex].execute();
  }

  canUndo(): boolean {
    return this.currentIndex >= 0;
  }

  canRedo(): boolean {
    return this.currentIndex < this.history.length - 1;
  }
}

// Usage
const doc = new TextDocument();
const history = new CommandHistory();

history.execute(new InsertCommand(doc, 'Hello', 0));
history.execute(new InsertCommand(doc, ' World', 5));
console.log(doc.getContent()); // "Hello World"

history.undo();
console.log(doc.getContent()); // "Hello"

history.redo();
console.log(doc.getContent()); // "Hello World"

Variations

  • Macro Command executes multiple commands as a single unit
  • Async Command returns a Promise for long-running operations
  • Composite Command treats a batch of commands as one undoable action

Production Considerations

  • Limit history size to prevent memory exhaustion in long sessions
  • Serialize commands to JSON for crash recovery and collaborative editing
  • Use immutable document states for simpler undo logic in functional architectures

Common Mistakes

  • Storing entire document snapshots instead of inverse operations
  • Not handling concurrent command execution in multi-user scenarios
  • Forgetting to clear redo stack when a new command is executed after undo

Troubleshooting

  • Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
  • Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
  • Tight coupling after refactoring: check that interfaces are stable and dependencies point inward.
  • Tests break when the design changes: favor stable contracts over internal structure.
  • Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.

Key Takeaways

  • Apply command pattern with undo/redo in typescript when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Advanced Topics

Scenario: Undo/Redo System for Text Editor

// Command pattern: encapsulate operations as objects
interface Command {
  execute(): void;
  undo(): void;
  describe(): string;
}

// Receiver: the text editor
class TextEditor {
  private content = "";
  private selection = { start: 0, end: 0 };

  insert(text: string, pos: number) {
    this.content = this.content.slice(0, pos) + text + this.content.slice(pos);
  }
  delete(start: number, end: number) {
    this.content = this.content.slice(0, start) + this.content.slice(end);
  }
  getContent(): string { return this.content; }
}

// Concrete commands
class InsertCommand implements Command {
  constructor(private editor: TextEditor, private text: string, private pos: number) {}
  execute() { this.editor.insert(this.text, this.pos); }
  undo() { this.editor.delete(this.pos, this.pos + this.text.length); }
  describe() { return `Insert "${this.text}" at ${this.pos}`; }
}

class DeleteCommand implements Command {
  private deletedText = "";
  constructor(private editor: TextEditor, private start: number, private end: number) {}
  execute() {
    this.deletedText = this.editor.getContent().slice(this.start, this.end);
    this.editor.delete(this.start, this.end);
  }
  undo() { this.editor.insert(this.deletedText, this.start); }
  describe() { return `Delete ${this.start}-${this.end}`; }
}

// Invoker: command history
class CommandHistory {
  private undoStack: Command[] = [];
  private redoStack: Command[] = [];
  private maxHistory = 100;

  execute(cmd: Command) {
    cmd.execute();
    this.undoStack.push(cmd);
    if (this.undoStack.length > this.maxHistory) this.undoStack.shift();
    this.redoStack = [];
  }
  undo(): Command | null {
    const cmd = this.undoStack.pop();
    if (cmd) { cmd.undo(); this.redoStack.push(cmd); }
    return cmd;
  }
  redo(): Command | null {
    const cmd = this.redoStack.pop();
    if (cmd) { cmd.execute(); this.undoStack.push(cmd); }
    return cmd;
  }
  canUndo(): boolean { return this.undoStack.length > 0; }
  canRedo(): boolean { return this.redoStack.length > 0; }
}

// Usage
const editor = new TextEditor();
const history = new CommandHistory();

history.execute(new InsertCommand(editor, "Hello", 0));
history.execute(new InsertCommand(editor, " World", 5));
console.log(editor.getContent()); // "Hello World"

history.undo();
console.log(editor.getContent()); // "Hello"

history.redo();
console.log(editor.getContent()); // "Hello World"

Lessons:

  • Command encapsulates operations as objects with execute and undo
  • The history handles undo/redo without knowing command details
  • Each command stores state needed to reverse itself
  • Limit history (100 commands) to avoid memory leaks
  • Macro command: group multiple commands into one

### How do I implement macros with Command?

Create a MacroCommand that contains a list of commands. Execute() calls execute() on each command in order. Undo() calls undo() in reverse order. This allows grouping atomic operations: for example, "format document" executes 20 individual commands, and a single undo reverses them all.

## Common Production Pitfalls

- Applying the pattern where no abstraction is needed, adding accidental complexity.
- Letting the pattern leak into unrelated modules and blur ownership boundaries.
- Over-engineering the first implementation instead of starting simple and measuring pain.
- Skipping contract tests, so refactors silently break consumers.
- Ignoring failure modes that the pattern does not cover.
- Using the pattern as a default instead of choosing the right tool for the current scale.
- Forgetting to document when to stop using the pattern and what replaces it.
- Missing observability around the pattern's performance and error propagation.

Frequently Asked Questions

How is this different from the Memento pattern?

Command stores the operation to reverse. Memento stores the state snapshot. Commands are smaller but harder to implement; Mementos are simpler but use more memory.

Can I use this for API request logging?

Yes. Wrap HTTP requests as commands to replay sequences for debugging or testing.