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