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
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Command Pattern with Undo/Redo in TypeScript
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
FAQ
Q: How is this different from the Memento pattern? A: 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.
Q: Can I use this for API request logging? A: Yes. Wrap HTTP requests as commands to replay sequences for debugging or testing.
Is this pattern suitable for small projects?
For small projects with few components, this pattern may add unnecessary complexity. Start simple and introduce the pattern when you feel the pain it solves.
How does this pattern compare to alternatives?
Each pattern makes different trade-offs. Review the variants table above and consider your specific constraints: team size, performance requirements, and future scaling plans.
Can I partially apply this pattern?
Yes. Many teams adopt patterns incrementally. Start with the core idea and add sophistication as needed. The pattern is a guide, not a strict blueprint.
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. Related Resources
Memento Pattern for State Snapshot and Restoration
Capture and externalize an object's internal state without violating encapsulation, enabling undo, serialization, and state rollback in applications
PatternAbstract Factory for Cross-Platform UI Component Families
Create families of related objects without specifying concrete classes, enabling platform-specific implementations that share a common interface
PatternDependency Injection Container in TypeScript
Build a lightweight DI container that resolves class dependencies automatically, enabling testable, loosely-coupled applications without frameworks like Angular or InversifyJS
PatternInterpreter Pattern for Domain-Specific Expression Languages
Build a language interpreter that evaluates expressions and rules by representing grammar as composable objects, useful for formulas, queries, and business rules
PatternVisitor Pattern for Extensible Operations on Object
Separate algorithms from the objects they operate on, allowing new operations to be added without modifying existing element classes