Loading content...
Imagine you're working on a document processing system. You have a well-designed class hierarchy representing different document elements: paragraphs, headings, images, tables, and code blocks. The system works beautifully for rendering documents to HTML.
Then requirements arrive:
Each new requirement means a new operation on every element type. How do you add these operations without constantly modifying your stable, well-tested element classes?
By the end of this page, you will deeply understand the problem of adding operations to closed class hierarchies—the tension between stability and extensibility that the Visitor pattern elegantly resolves. You'll see why naive approaches fail and how this problem appears across domains.
Well-designed object-oriented systems strive for stable class hierarchies. Once a class is tested, documented, and deployed, modifying it introduces risk:
Yet real-world requirements constantly demand new operations on existing object structures. This creates a fundamental paradox:
The more stable and well-tested a class hierarchy becomes, the more painful it is to add new operations—yet stable hierarchies are precisely where new requirements accumulate.
Mature systems with stable domain models face this paradox most acutely. The document processor example isn't hypothetical—it represents any system with stable entities and evolving behavior requirements.
The Two Dimensions of Extension
Object-oriented design offers two orthogonal dimensions for extension:
| Dimension | Mechanism | Easy When... | Hard When... |
|---|---|---|---|
| Add New Types | Inheritance/Polymorphism | Behavior is fixed | Operations must be added |
| Add New Operations | Method addition | Class hierarchy is open | Classes are closed/stable |
Most OOP systems excel at adding new types. Want a new document element? Create a VideoElement class implementing the existing interface. The system handles it naturally through polymorphism.
But adding new operations—that's where traditional OOP struggles. Each new operation requires modifying every class in the hierarchy.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Initial design: Rendering to HTMLinterface DocumentElement { renderHtml(): string;} class Paragraph implements DocumentElement { constructor(private text: string) {} renderHtml(): string { return `<p>${this.text}</p>`; }} class Heading implements DocumentElement { constructor(private text: string, private level: number) {} renderHtml(): string { return `<h${this.level}>${this.text}</h${this.level}>`; }} class Image implements DocumentElement { constructor(private src: string, private alt: string) {} renderHtml(): string { return `<img src="${this.src}" alt="${this.alt}" />`; }} class CodeBlock implements DocumentElement { constructor(private code: string, private language: string) {} renderHtml(): string { return `<pre><code class="language-${this.language}">${this.code}</code></pre>`; }} // ❌ PROBLEM: Now we need PDF export. We must modify EVERY class: interface DocumentElement { renderHtml(): string; renderPdf(): PdfNode; // Added to interface} class Paragraph implements DocumentElement { renderHtml(): string { /* ... */ } renderPdf(): PdfNode { // Added to Paragraph return new PdfTextBlock(this.text, { font: 'serif', size: 12 }); }} class Heading implements DocumentElement { renderHtml(): string { /* ... */ } renderPdf(): PdfNode { // Added to Heading const sizes = { 1: 24, 2: 20, 3: 16, 4: 14, 5: 12, 6: 10 }; return new PdfTextBlock(this.text, { font: 'sans-serif', size: sizes[this.level], bold: true }); }} // ... and so on for Image, CodeBlock, Table, etc. // ❌ WORSE: Now we need word count, accessibility check, TOC generation...// Each operation requires modifying EVERY class in the hierarchy!Before exploring the Visitor pattern, let's examine why common alternatives fall short. Understanding their limitations illuminates what problem we're actually solving.
renderPdf(), countWords(), etc.) directly to each element class.123456789101112131415161718192021222324252627282930313233
// Every class becomes bloated with unrelated operations:class Paragraph implements DocumentElement { constructor(private text: string) {} // Core functionality renderHtml(): string { /* ... */ } // PDF requirement renderPdf(): PdfNode { /* ... */ } // Analytics requirement countWords(): number { /* ... */ } // Accessibility requirement validateAccessibility(): AccessibilityReport { /* ... */ } // TOC requirement extractHeadings(): Heading[] { return []; } // Not a heading, so empty // Search requirement extractSearchableText(): string { /* ... */ } // Comparison requirement structuralHash(): string { /* ... */ } // ... 10 more operations accumulate over time} // ❌ Problems:// 1. Class has too many responsibilities (SRP violation)// 2. Every new operation opens every class for modification// 3. Operations team can't add features without element team review// 4. 20 classes × 15 operations = 300 methods to maintaininstanceof or type tags.12345678910111213141516171819202122232425262728293031323334353637383940
// External operations check types:function renderPdf(element: DocumentElement): PdfNode { if (element instanceof Paragraph) { return new PdfTextBlock(element.getText(), { font: 'serif', size: 12 }); } else if (element instanceof Heading) { const sizes = { 1: 24, 2: 20, 3: 16, 4: 14, 5: 12, 6: 10 }; return new PdfTextBlock(element.getText(), { font: 'sans-serif', size: sizes[element.getLevel()], bold: true }); } else if (element instanceof Image) { return new PdfImage(element.getSrc()); } else if (element instanceof CodeBlock) { return new PdfCodeBlock(element.getCode(), element.getLanguage()); } else if (element instanceof Table) { return new PdfTable(element.getRows()); } // ... 15 more type checks throw new Error(`Unknown element type: ${element.constructor.name}`);} function countWords(element: DocumentElement): number { if (element instanceof Paragraph) { return element.getText().split(/\s+/).length; } else if (element instanceof Heading) { return element.getText().split(/\s+/).length; } else if (element instanceof Image) { return element.getAlt().split(/\s+/).length; } // ... same exhaustive type checking repeated} // ❌ Problems:// 1. Runtime errors if new types aren't handled// 2. No compile-time exhaustiveness checking// 3. Each operation duplicates the type-checking logic// 4. Logic for one operation scattered if types are added// 5. Breaks encapsulation - operations need access to internal state123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Parallel renderer hierarchies for each operation: // PDF Renderersinterface PdfRenderer<T extends DocumentElement> { render(element: T): PdfNode;} class ParagraphPdfRenderer implements PdfRenderer<Paragraph> { render(element: Paragraph): PdfNode { /* ... */ }} class HeadingPdfRenderer implements PdfRenderer<Heading> { render(element: Heading): PdfNode { /* ... */ }} class ImagePdfRenderer implements PdfRenderer<Image> { render(element: Image): PdfNode { /* ... */ }} // Word Countersinterface WordCounter<T extends DocumentElement> { count(element: T): number;} class ParagraphWordCounter implements WordCounter<Paragraph> { count(element: Paragraph): number { /* ... */ }} class HeadingWordCounter implements WordCounter<Heading> { count(element: Heading): number { /* ... */ }} // Accessibility Validators, TOC Extractors, Search Indexers... // ❌ For 20 element types × 10 operations = 200 classes! // Plus you need registry/factory to connect them:class RendererRegistry { private pdfRenderers: Map<Constructor, PdfRenderer<any>> = new Map(); private wordCounters: Map<Constructor, WordCounter<any>> = new Map(); // ... maps for each operation type constructor() { this.pdfRenderers.set(Paragraph, new ParagraphPdfRenderer()); this.pdfRenderers.set(Heading, new HeadingPdfRenderer()); // ... 18 more registrations for PDF // ... then repeat for each operation type } getPdfRenderer(element: DocumentElement): PdfRenderer<DocumentElement> { const renderer = this.pdfRenderers.get(element.constructor); if (!renderer) throw new Error('No renderer registered'); return renderer; }} // ❌ This is becoming a maintenance nightmareAll naive approaches suffer from the same fundamental tension: adding a new operation requires changes proportional to the number of types, or adding a new type requires changes proportional to the number of operations.
We can't escape this mathematical reality—but we CAN choose which dimension we optimize for. The Visitor pattern chooses to optimize for adding operations when types are stable.
The operation-extension problem isn't equally painful in all scenarios. Understanding when it's most acute helps you recognize Visitor pattern opportunities.
| Characteristic | Description | Example |
|---|---|---|
| Stable Type Hierarchy | Element types change rarely after initial design | AST nodes in a mature language |
| Frequent Operation Addition | New operations are added more often than new types | IDE features for code analysis |
| Complex Object Structures | Elements form trees or graphs needing traversal | Document DOMs, scene graphs |
| Operations Cross-Cut Types | Same operation applies to all/most types | Serialization, validation |
| Separation of Concerns | Operation logic should be separate from element logic | Rendering separate from data model |
Compilers are the canonical example. Language syntax (AST node types) changes slowly—maybe a few new constructs per major version. But operations on the AST are added continually: type inference, dead code elimination, constant folding, register allocation, various code generation targets, IDE features like go-to-definition, find-references, refactoring.
The Visitor pattern was essentially invented for this domain, though it applies broadly.
Let's examine the problem through the lens of a simple expression AST. This example illustrates how quickly operations accumulate on a stable type hierarchy.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// A simple expression language AST// These types are STABLE - they define what expressions can exist abstract class Expression { abstract evaluate(): number; // The only built-in operation} class NumberLiteral extends Expression { constructor(public readonly value: number) { super(); } evaluate(): number { return this.value; }} class BinaryOperation extends Expression { constructor( public readonly left: Expression, public readonly operator: '+' | '-' | '*' | '/', public readonly right: Expression ) { super(); } evaluate(): number { const l = this.left.evaluate(); const r = this.right.evaluate(); switch (this.operator) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return l / r; } }} class UnaryOperation extends Expression { constructor( public readonly operator: '-' | '+', public readonly operand: Expression ) { super(); } evaluate(): number { const val = this.operand.evaluate(); return this.operator === '-' ? -val : val; }} class VariableReference extends Expression { constructor( public readonly name: string, private readonly environment: Map<string, number> ) { super(); } evaluate(): number { const value = this.environment.get(this.name); if (value === undefined) { throw new Error(`Undefined variable: ${this.name}`); } return value; }} class FunctionCall extends Expression { constructor( public readonly functionName: string, public readonly args: Expression[] ) { super(); } evaluate(): number { // Built-in functions const argValues = this.args.map(a => a.evaluate()); switch (this.functionName) { case 'sqrt': return Math.sqrt(argValues[0]); case 'pow': return Math.pow(argValues[0], argValues[1]); case 'abs': return Math.abs(argValues[0]); default: throw new Error(`Unknown function: ${this.functionName}`); } }}This AST is clean and focused. Each node knows how to evaluate itself. But then requirements arrive:
Week 1: "We need to pretty-print expressions for debugging." Week 2: "Add support for serializing expressions to JSON." Week 3: "We need type checking for a statically-typed variant." Week 4: "Add optimization passes—constant folding, redundant operation elimination." Week 5: "Generate LLVM IR for JIT compilation." Week 6: "Collect variable references for scope analysis." Week 7: "Compute expression complexity for query planning."
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Without Visitor pattern, Expression classes bloat: abstract class Expression { // Core abstract evaluate(): number; // Pretty printing abstract prettyPrint(): string; abstract prettyPrintWithParens(): string; // Serialization abstract toJson(): object; abstract toXml(): string; // Type checking abstract inferType(): ExpressionType; abstract checkType(expected: ExpressionType): TypeError[]; // Optimization abstract foldConstants(): Expression; abstract simplify(): Expression; abstract isConstant(): boolean; // Code generation abstract generateLlvmIr(builder: IrBuilder): Value; abstract generateJvmBytecode(writer: BytecodeWriter): void; // Analysis abstract collectVariables(): Set<string>; abstract computeDepth(): number; abstract computeComplexity(): number; // And more operations accumulate...} // Every concrete class must implement ALL of these:class NumberLiteral extends Expression { evaluate(): number { /* ... */ } prettyPrint(): string { /* ... */ } prettyPrintWithParens(): string { /* ... */ } toJson(): object { /* ... */ } toXml(): string { /* ... */ } inferType(): ExpressionType { /* ... */ } checkType(expected: ExpressionType): TypeError[] { /* ... */ } foldConstants(): Expression { /* ... */ } simplify(): Expression { /* ... */ } isConstant(): boolean { /* ... */ } generateLlvmIr(builder: IrBuilder): Value { /* ... */ } generateJvmBytecode(writer: BytecodeWriter): void { /* ... */ } collectVariables(): Set<string> { /* ... */ } computeDepth(): number { /* ... */ } computeComplexity(): number { /* ... */ } // ... 15+ methods in a "simple" number literal class} // ❌ Problems:// 1. Expression classes have become god classes// 2. Adding new operations requires changing 5+ files// 3. LLVM code generation logic mixed with JSON serialization// 4. Can't version operations independently// 5. Testing nightmare - each class tests 15+ behaviorsThis isn't theoretical. Production compilers, interpreters, and document processors that don't use the Visitor pattern (or an equivalent) become unmaintainable. Adding a single operation to TypeScript's AST would require changes to hundreds of node type classes. The Visitor pattern is why they remain maintainable.
What we've been exploring has a formal name in programming language theory: The Expression Problem. Coined by Philip Wadler in 1998, it captures a fundamental tension in language design.
The Expression Problem: Define a data type by cases, where one can add new cases to the data type and new functions over the data type, without recompiling existing code, while retaining static type safety.
— Philip Wadler, 1998
The problem reveals a fundamental asymmetry between object-oriented and functional approaches:
Object-Oriented Style (Classes + Methods):
Functional Style (Data Types + Pattern Matching):
Neither approach solves both dimensions simultaneously without trade-offs.
| Paradigm | Add New Type | Add New Operation | Type Safety |
|---|---|---|---|
| Object-Oriented (Methods in Classes) | ✅ Easy - new subclass | ❌ Hard - modify all classes | ✅ Static |
| Functional (Pattern Matching) | ❌ Hard - modify all functions | ✅ Easy - new function | ✅ Static |
| Visitor Pattern | ❌ Hard - modify all visitors | ✅ Easy - new visitor | ✅ Static |
| Type Classes (Haskell) | ✅ Moderate - new instance | ✅ Moderate - new class | ✅ Static |
The Visitor Pattern's Trade-off
The Visitor pattern explicitly chooses one side of the trade-off:
This is the right choice when:
It's the wrong choice when element types change frequently—in that case, traditional polymorphism is better.
123456789101112131415161718192021222324252627282930313233343536373839404142
The Expression Problem Matrix:═══════════════════════════════════════════════════════════════ OPERATIONS ┌─────────────────┐ │ Op1 Op2 Op3 ... ┌───────┼─────────────────┤ │TypeA │ ✓ ✓ ✓ │ TYPES │TypeB │ ✓ ✓ ✓ │ ← Each cell is an │TypeC │ ✓ ✓ ✓ │ implementation │... │ │ └───────┴─────────────────┘ OO Approach: Easy to add rows (types), hard to add columns (operations)FP Approach: Easy to add columns (operations), hard to add rows (types)Visitor: Explicitly chooses to add columns easily ═══════════════════════════════════════════════════════════════ Adding a NEW TYPE (with Visitor): OPERATIONS ┌─────────────────────┐ │ Op1 Op2 Op3 ... ┌───────┼─────────────────────┤ │TypeA │ ✓ ✓ ✓ │ TYPES │TypeB │ ✓ ✓ ✓ │ │TypeC │ ✓ ✓ ✓ │ │TypeD │ ? ? ? │ ← Must implement in EVERY visitor! └───────┴─────────────────────┘ Adding a NEW OPERATION (with Visitor): OPERATIONS ┌────────────────────────┐ │ Op1 Op2 Op3 Op4 ... ┌───────┼────────────────────────┤ │TypeA │ ✓ ✓ ✓ ? │ TYPES │TypeB │ ✓ ✓ ✓ ? │ All in ONE new file! │TypeC │ ✓ ✓ ✓ ? │ └───────┴────────────────────────┘ ↑ One new visitor class handles all typesTo truly understand the Visitor pattern, we need to understand why object-oriented polymorphism—despite its power—can't solve the operation-extension problem.
Single Dispatch: The Root Limitation
Most object-oriented languages use single dispatch: method selection is based solely on the runtime type of the receiver object (the object before the dot).
element.render(); // Method selected based on element's runtime type
When you call render(), the language looks up which render implementation to execute based on whether element is a Paragraph, Heading, Image, etc. This is polymorphism—elegant and powerful.
But what if the behavior depends on two types? What if rendering depends on both:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// We want behavior to depend on TWO types:// 1. What kind of element is this?// 2. What kind of output do we want? abstract class Element { // This method only dispatches on Element type abstract render(renderer: Renderer): string;} abstract class Renderer { // This method only dispatches on Renderer type (if called on Renderer) abstract renderParagraph(p: Paragraph): string; abstract renderHeading(h: Heading): string; // ...} // With single dispatch, we can only pick ONE to be polymorphic: // Option A: Dispatch on Element, but Renderer type is staticclass Paragraph extends Element { render(renderer: Renderer): string { // We know this is a Paragraph, but what KIND of Renderer? // We'd have to use instanceof checks: if (renderer instanceof HtmlRenderer) { return `<p>${this.text}</p>`; } else if (renderer instanceof PdfRenderer) { return renderer.createPdfParagraph(this.text); } // ❌ Back to type checking! }} // Option B: Dispatch on Renderer, but Element type is staticclass HtmlRenderer extends Renderer { renderAny(element: Element): string { // We know this is HtmlRenderer, but what KIND of Element? if (element instanceof Paragraph) { return `<p>${element.text}</p>`; } else if (element instanceof Heading) { return `<h${element.level}>${element.text}</h${element.level}>`; } // ❌ Also back to type checking! }} // Neither approach achieves polymorphism on BOTH dimensions!The Visitor pattern's genius is simulating double dispatch (dispatch based on two types) using two single dispatches. The first dispatch is on the element type (via accept), the second is on the visitor type (via the specific visit method called). Together, they achieve behavior that depends on both types—without any instanceof checks.
Let's consolidate everything we've learned about the operation-extension problem:
You now understand the problem that the Visitor pattern solves: how to add new operations to a stable object structure without modifying its classes. You've seen why naive approaches fail and where this problem is most acute. Next, we'll explore the Visitor pattern's elegant solution using double dispatch—turning the single-dispatch limitation into an advantage.