15 minlesson

Double Dispatch and Using the Visitor Pattern

Double Dispatch and Using the Visitor Pattern

Overview

In this lesson, we'll explore the double dispatch mechanism that powers the Visitor pattern, how Visitor works with the Composite pattern, and guidelines for when to use Visitor in your applications.

Duration: 15 minutes

Understanding Double Dispatch

Single dispatch (normal method calls) selects a method based on the runtime type of one object:

typescript
1class Package {
2 process(): void {
3 console.log('Processing package');
4 }
5}
6
7class FragilePackage extends Package {
8 process(): void {
9 console.log('Processing fragile package carefully');
10 }
11}
12
13const pkg: Package = new FragilePackage();
14pkg.process(); // "Processing fragile package carefully"
15// Method selected based only on runtime type of 'pkg'

Double dispatch selects a method based on the runtime types of two objects:

typescript
1// First dispatch: based on package type
2package.accept(visitor); // Which accept() method?
3
4// Second dispatch: based on visitor type
5visitor.visitFragilePackage(this); // Which visitFragilePackage() method?

How Double Dispatch Works

Let's trace through a complete example:

typescript
1interface PackageVisitor {
2 visitStandardPackage(pkg: StandardPackage): void;
3 visitFragilePackage(pkg: FragilePackage): void;
4}
5
6interface Package {
7 accept(visitor: PackageVisitor): void;
8}
9
10class StandardPackage implements Package {
11 accept(visitor: PackageVisitor): void {
12 visitor.visitStandardPackage(this); // "this" is StandardPackage
13 }
14}
15
16class FragilePackage implements Package {
17 accept(visitor: PackageVisitor): void {
18 visitor.visitFragilePackage(this); // "this" is FragilePackage
19 }
20}
21
22class TaxCalculator implements PackageVisitor {
23 visitStandardPackage(pkg: StandardPackage): void {
24 console.log('Calculating standard tax');
25 }
26
27 visitFragilePackage(pkg: FragilePackage): void {
28 console.log('Calculating fragile tax');
29 }
30}
31
32class Inspector implements PackageVisitor {
33 visitStandardPackage(pkg: StandardPackage): void {
34 console.log('Standard inspection');
35 }
36
37 visitFragilePackage(pkg: FragilePackage): void {
38 console.log('Fragile inspection');
39 }
40}
41
42// Usage
43const packages: Package[] = [
44 new StandardPackage(),
45 new FragilePackage()
46];
47
48const taxCalc = new TaxCalculator();
49const inspector = new Inspector();
50
51packages[0].accept(taxCalc); // "Calculating standard tax"
52packages[1].accept(taxCalc); // "Calculating fragile tax"
53packages[0].accept(inspector); // "Standard inspection"
54packages[1].accept(inspector); // "Fragile inspection"

Execution trace for packages[1].accept(taxCalc):

  1. Runtime determines packages[1] is FragilePackage (first dispatch)
  2. Calls FragilePackage.accept(taxCalc)
  3. Inside accept(), this is FragilePackage
  4. Calls taxCalc.visitFragilePackage(this)
  5. Runtime determines taxCalc is TaxCalculator (second dispatch)
  6. Calls TaxCalculator.visitFragilePackage(pkg)
  7. Executes "Calculating fragile tax"

The correct method is selected based on both the package type (FragilePackage) and the visitor type (TaxCalculator).

Why Double Dispatch Matters

Without double dispatch, you'd need type checks:

typescript
1// BAD: Using instanceof (violates Open/Closed Principle)
2class TaxCalculator {
3 calculate(pkg: Package): number {
4 if (pkg instanceof StandardPackage) {
5 return this.calculateStandard(pkg as StandardPackage);
6 } else if (pkg instanceof FragilePackage) {
7 return this.calculateFragile(pkg as FragilePackage);
8 } else if (pkg instanceof HazmatPackage) {
9 return this.calculateHazmat(pkg as HazmatPackage);
10 }
11 throw new Error('Unknown package type');
12 }
13}
14
15// GOOD: Using Visitor (double dispatch)
16class TaxCalculator implements PackageVisitor<number> {
17 visitStandardPackage(pkg: StandardPackage): number {
18 return pkg.getWeight() * 0.05;
19 }
20
21 visitFragilePackage(pkg: FragilePackage): number {
22 return pkg.getWeight() * 0.08;
23 }
24
25 visitHazmatPackage(pkg: HazmatPackage): number {
26 return pkg.getWeight() * 0.15;
27 }
28}
29
30// Usage: no type checking needed
31const tax = pkg.accept(new TaxCalculator());

Visitor with Composite Pattern

The Visitor pattern works exceptionally well with the Composite pattern for traversing hierarchical structures:

typescript
1// Composite structure: Shipment contains packages
2interface ShipmentComponent {
3 accept(visitor: ShipmentVisitor): void;
4}
5
6interface ShipmentVisitor {
7 visitPackage(pkg: Package): void;
8 visitContainer(container: Container): void;
9 visitShipment(shipment: Shipment): void;
10}
11
12class Package implements ShipmentComponent {
13 constructor(
14 private id: string,
15 private weight: number,
16 private value: number
17 ) {}
18
19 accept(visitor: ShipmentVisitor): void {
20 visitor.visitPackage(this);
21 }
22
23 getId(): string { return this.id; }
24 getWeight(): number { return this.weight; }
25 getValue(): number { return this.value; }
26}
27
28class Container implements ShipmentComponent {
29 private packages: Package[] = [];
30
31 constructor(private id: string) {}
32
33 addPackage(pkg: Package): void {
34 this.packages.push(pkg);
35 }
36
37 accept(visitor: ShipmentVisitor): void {
38 visitor.visitContainer(this);
39 // Traverse children
40 this.packages.forEach(pkg => pkg.accept(visitor));
41 }
42
43 getId(): string { return this.id; }
44 getPackages(): Package[] { return [...this.packages]; }
45}
46
47class Shipment implements ShipmentComponent {
48 private containers: Container[] = [];
49
50 constructor(private id: string, private destination: string) {}
51
52 addContainer(container: Container): void {
53 this.containers.push(container);
54 }
55
56 accept(visitor: ShipmentVisitor): void {
57 visitor.visitShipment(this);
58 // Traverse children
59 this.containers.forEach(container => container.accept(visitor));
60 }
61
62 getId(): string { return this.id; }
63 getDestination(): string { return this.destination; }
64}
65
66// Visitor to calculate total weight
67class WeightCalculatorVisitor implements ShipmentVisitor {
68 private totalWeight = 0;
69
70 visitPackage(pkg: Package): void {
71 this.totalWeight += pkg.getWeight();
72 }
73
74 visitContainer(container: Container): void {
75 // Container weight is handled by its packages
76 // Just add container's own weight
77 this.totalWeight += 5; // 5kg container weight
78 }
79
80 visitShipment(shipment: Shipment): void {
81 // Shipment overhead
82 this.totalWeight += 2; // 2kg for paperwork, labels, etc.
83 }
84
85 getTotalWeight(): number {
86 return this.totalWeight;
87 }
88}
89
90// Visitor to calculate total value
91class ValueCalculatorVisitor implements ShipmentVisitor {
92 private totalValue = 0;
93
94 visitPackage(pkg: Package): void {
95 this.totalValue += pkg.getValue();
96 }
97
98 visitContainer(container: Container): void {
99 // Containers don't have value themselves
100 }
101
102 visitShipment(shipment: Shipment): void {
103 // Shipments don't have value themselves
104 }
105
106 getTotalValue(): number {
107 return this.totalValue;
108 }
109}
110
111// Visitor to generate report
112class ReportGeneratorVisitor implements ShipmentVisitor {
113 private lines: string[] = [];
114 private indent = 0;
115
116 visitShipment(shipment: Shipment): void {
117 this.lines.push(`Shipment ${shipment.getId()} to ${shipment.getDestination()}`);
118 this.indent++;
119 }
120
121 visitContainer(container: Container): void {
122 this.lines.push(`${' '.repeat(this.indent)}Container ${container.getId()} (${container.getPackages().length} packages)`);
123 this.indent++;
124 }
125
126 visitPackage(pkg: Package): void {
127 this.lines.push(`${' '.repeat(this.indent)}Package ${pkg.getId()}: ${pkg.getWeight()}kg, $${pkg.getValue()}`);
128 }
129
130 getReport(): string {
131 return this.lines.join('\n');
132 }
133}
134
135// Usage
136const shipment = new Shipment('SHIP001', 'Los Angeles');
137
138const container1 = new Container('CNT001');
139container1.addPackage(new Package('PKG001', 10, 100));
140container1.addPackage(new Package('PKG002', 5, 50));
141
142const container2 = new Container('CNT002');
143container2.addPackage(new Package('PKG003', 8, 200));
144container2.addPackage(new Package('PKG004', 12, 150));
145
146shipment.addContainer(container1);
147shipment.addContainer(container2);
148
149// Calculate total weight
150const weightCalc = new WeightCalculatorVisitor();
151shipment.accept(weightCalc);
152console.log(`Total weight: ${weightCalc.getTotalWeight()}kg`);
153// Output: Total weight: 47kg (10+5+8+12 packages + 5+5 containers + 2 shipment)
154
155// Calculate total value
156const valueCalc = new ValueCalculatorVisitor();
157shipment.accept(valueCalc);
158console.log(`Total value: $${valueCalc.getTotalValue()}`);
159// Output: Total value: $500
160
161// Generate report
162const reportGen = new ReportGeneratorVisitor();
163shipment.accept(reportGen);
164console.log(reportGen.getReport());
165// Output:
166// Shipment SHIP001 to Los Angeles
167// Container CNT001 (2 packages)
168// Package PKG001: 10kg, $100
169// Package PKG002: 5kg, $50
170// Container CNT002 (2 packages)
171// Package PKG003: 8kg, $200
172// Package PKG004: 12kg, $150

Adding New Operations vs. New Elements

Easy: Adding New Operations

Adding a new operation is trivial - just create a new visitor:

typescript
1// Add customs declaration visitor
2class CustomsDeclarationVisitor implements ShipmentVisitor {
3 private declarations: string[] = [];
4
5 visitPackage(pkg: Package): void {
6 this.declarations.push(
7 `Item ${pkg.getId()}: Commercial value $${pkg.getValue()}, Weight ${pkg.getWeight()}kg`
8 );
9 }
10
11 visitContainer(container: Container): void {
12 this.declarations.push(`Container ${container.getId()}`);
13 }
14
15 visitShipment(shipment: Shipment): void {
16 this.declarations.push(`Destination: ${shipment.getDestination()}`);
17 }
18
19 getDeclaration(): string {
20 return this.declarations.join('\n');
21 }
22}
23
24// Use it immediately without modifying any existing code
25const customsDecl = new CustomsDeclarationVisitor();
26shipment.accept(customsDecl);
27console.log(customsDecl.getDeclaration());

Hard: Adding New Elements

Adding a new element type requires updating all existing visitors:

typescript
1// Add a new element type: Pallet
2class Pallet implements ShipmentComponent {
3 private containers: Container[] = [];
4
5 constructor(private id: string) {}
6
7 addContainer(container: Container): void {
8 this.containers.push(container);
9 }
10
11 accept(visitor: ShipmentVisitor): void {
12 visitor.visitPallet(this); // Error: visitPallet doesn't exist!
13 this.containers.forEach(c => c.accept(visitor));
14 }
15
16 getId(): string { return this.id; }
17}
18
19// Must update the visitor interface
20interface ShipmentVisitor {
21 visitPackage(pkg: Package): void;
22 visitContainer(container: Container): void;
23 visitShipment(shipment: Shipment): void;
24 visitPallet(pallet: Pallet): void; // New method
25}
26
27// Must update ALL existing visitors
28class WeightCalculatorVisitor implements ShipmentVisitor {
29 // ... existing methods ...
30
31 visitPallet(pallet: Pallet): void { // Must implement
32 this.totalWeight += 20; // 20kg pallet weight
33 }
34}
35
36class ValueCalculatorVisitor implements ShipmentVisitor {
37 // ... existing methods ...
38
39 visitPallet(pallet: Pallet): void { // Must implement
40 // Pallets have no value
41 }
42}
43
44class ReportGeneratorVisitor implements ShipmentVisitor {
45 // ... existing methods ...
46
47 visitPallet(pallet: Pallet): void { // Must implement
48 this.lines.push(`${' '.repeat(this.indent)}Pallet ${pallet.getId()}`);
49 this.indent++;
50 }
51}
52
53class CustomsDeclarationVisitor implements ShipmentVisitor {
54 // ... existing methods ...
55
56 visitPallet(pallet: Pallet): void { // Must implement
57 this.declarations.push(`Pallet ${pallet.getId()}`);
58 }
59}

When to Use the Visitor Pattern

Good Use Cases

1. Stable Element Hierarchy with Varying Operations

typescript
1// Element hierarchy rarely changes
2interface ASTNode { accept(visitor: ASTVisitor): void; }
3class VariableNode implements ASTNode { /* ... */ }
4class FunctionNode implements ASTNode { /* ... */ }
5class BinaryOpNode implements ASTNode { /* ... */ }
6
7// Operations change frequently
8class TypeCheckerVisitor implements ASTVisitor { /* ... */ }
9class CodeGeneratorVisitor implements ASTVisitor { /* ... */ }
10class OptimizationVisitor implements ASTVisitor { /* ... */ }
11class DocumentationVisitor implements ASTVisitor { /* ... */ }

2. Operations That Accumulate State

typescript
1class ShipmentAnalyticsVisitor implements ShipmentVisitor {
2 private stats = {
3 totalPackages: 0,
4 totalContainers: 0,
5 totalWeight: 0,
6 totalValue: 0,
7 avgPackageWeight: 0,
8 avgPackageValue: 0
9 };
10
11 visitPackage(pkg: Package): void {
12 this.stats.totalPackages++;
13 this.stats.totalWeight += pkg.getWeight();
14 this.stats.totalValue += pkg.getValue();
15 }
16
17 visitContainer(container: Container): void {
18 this.stats.totalContainers++;
19 }
20
21 visitShipment(shipment: Shipment): void {
22 // Calculate averages
23 if (this.stats.totalPackages > 0) {
24 this.stats.avgPackageWeight = this.stats.totalWeight / this.stats.totalPackages;
25 this.stats.avgPackageValue = this.stats.totalValue / this.stats.totalPackages;
26 }
27 }
28
29 getStats() { return this.stats; }
30}

3. Export/Serialization to Different Formats

typescript
1class XMLExportVisitor implements ShipmentVisitor { /* ... */ }
2class JSONExportVisitor implements ShipmentVisitor { /* ... */ }
3class CSVExportVisitor implements ShipmentVisitor { /* ... */ }
4class PDFReportVisitor implements ShipmentVisitor { /* ... */ }

4. Complex Validation with Multiple Rules

typescript
1class WeightLimitValidator implements ShipmentVisitor { /* ... */ }
2class DimensionValidator implements ShipmentVisitor { /* ... */ }
3class HazmatComplianceValidator implements ShipmentVisitor { /* ... */ }
4class CustomsRequirementsValidator implements ShipmentVisitor { /* ... */ }

Poor Use Cases

1. Frequently Changing Element Hierarchy

If you're constantly adding new element types, Visitor becomes a maintenance burden because every new type requires updating all visitors.

2. Simple Operations Without State

If operations are simple and don't need to accumulate state, regular methods might be simpler:

typescript
1// DON'T use Visitor for this
2interface Package {
3 accept(visitor: PackageVisitor): void;
4}
5
6class WeightVisitor implements PackageVisitor {
7 visitPackage(pkg: Package): number {
8 return pkg.getWeight(); // Too simple for Visitor
9 }
10}
11
12// DO this instead
13interface Package {
14 getWeight(): number;
15}

3. When Encapsulation is Critical

Visitors often need access to element internals, which can break encapsulation:

typescript
1class FragilePackage {
2 private paddingThickness: number; // Private detail
3
4 accept(visitor: PackageVisitor): void {
5 visitor.visitFragilePackage(this);
6 }
7
8 // Must expose private detail for visitor
9 getPaddingThickness(): number {
10 return this.paddingThickness;
11 }
12}

Visitor Pattern Trade-offs

AspectBenefitTrade-off
Adding operationsVery easy - just create new visitorN/A
Adding elementsN/AHard - must update all visitors
Related logicKept together in one visitor classLogic split from data
Single ResponsibilityEach visitor has one purposeN/A
Open/Closed PrincipleOpen to new operationsClosed to new element types
EncapsulationN/AOften must expose element internals
Type safetyTypeScript ensures all types handledN/A
ComplexityClean separation of concernsDouble dispatch can be hard to understand

Key Takeaways

  • Double dispatch selects methods based on two runtime types (element and visitor)
  • Works great with Composite for traversing hierarchical structures
  • Easy to add operations - create new visitor without changing elements
  • Hard to add elements - new element types require updating all visitors
  • Use when: Element hierarchy is stable, operations vary, need state accumulation
  • Avoid when: Element types change frequently, operations are trivial, encapsulation is critical
  • Common uses: Compilers (AST visitors), reporting, export, validation, analysis

The Visitor pattern is a powerful tool for separating operations from data structures, especially when you have a stable set of types but need to perform many different operations on them.