Guide
Visitor and Double Dispatch
Why overloading alone cannot pick a method by an argument's runtime type, how Visitor uses double dispatch to fix it, and when pattern matching replaces it.
- Also known as
- Double dispatch, Single dispatch
The problem
You have a tree of node types (say, invoice line items) and you need many operations over them: compute tax, render HTML, export to an accounting system. You don't want each node class to grow a method for every operation. You want each operation in one place.
The natural first attempt is a class per operation with one overload per node type. In most mainstream languages, that attempt fails quietly.
Single dispatch
Languages like Java, C# and C++ pick a method in two stages:
- Which object's method is chosen at runtime, from the receiver's actual type. This is ordinary polymorphism, called single dispatch.
- Which overload is chosen at compile time, from the argument's declared type.
interface Item {}
class Product implements Item {}
class Shipping implements Item {}
class TaxCalculator {
long tax(Product p) { return 20; }
long tax(Shipping s) { return 5; }
long tax(Item i) { return 0; }
}
Item item = new Product();
new TaxCalculator().tax(item); // returns 0, not 20The compiler sees Item, so it binds to tax(Item). The runtime type Product is never consulted for overload selection. TypeScript and Python don't have this trap because they have no runtime overloading at all; there you would write a type check by hand.
Double dispatch
The fix is to make two runtime dispatches in a row. First, call a method on the element, so its real type is known inside it. Then the element calls back to the operation, passing this, whose static type is now the concrete class.
interface ItemVisitor<R> {
visitProduct(p: Product): R;
visitShipping(s: Shipping): R;
}
interface Item {
accept<R>(v: ItemVisitor<R>): R;
}
class Product implements Item {
constructor(readonly price: number, readonly taxRate: number) {}
accept<R>(v: ItemVisitor<R>): R { return v.visitProduct(this); }
}
class Shipping implements Item {
constructor(readonly fee: number) {}
accept<R>(v: ItemVisitor<R>): R { return v.visitShipping(this); }
}
class TaxVisitor implements ItemVisitor<number> {
visitProduct(p: Product) { return p.price * p.taxRate; }
visitShipping(s: Shipping) { return s.fee * 0.05; }
}
const items: Item[] = [new Product(100, 0.2), new Shipping(10)];
const tax = items.reduce((sum, i) => sum + i.accept(new TaxVisitor()), 0); // 20.5Dispatch one picks Product.accept. Dispatch two picks visitProduct on whichever visitor was passed. The operation now depends on two runtime types: the element's and the visitor's.
Diagram source (Mermaid)
sequenceDiagram participant Client participant Product participant TaxVisitor Client->>Product: accept(taxVisitor) Product->>TaxVisitor: visitProduct(this) TaxVisitor-->>Product: tax amount Product-->>Client: tax amount
This is the core of the Visitor pattern.
Trade-off: which axis is cheap to extend
| Change | Visitor | Methods on each node |
|---|---|---|
| Add an operation | One new visitor class | Edit every node class |
| Add a node type | Edit every visitor | One new class |
Use Visitor when the set of node types is stable and operations keep growing. Compilers, AST tools and document exporters fit. If node types change often, put the method on each node instead, using Replace Conditional with Polymorphism.
When pattern matching replaces Visitor
Languages with sealed types or discriminated unions plus exhaustive matching give you the same guarantees with less code. TypeScript can do this with a discriminated union and a never check:
type Item =
| { kind: "product"; price: number; taxRate: number }
| { kind: "shipping"; fee: number };
function tax(item: Item): number {
switch (item.kind) {
case "product": return item.price * item.taxRate;
case "shipping": return item.fee * 0.05;
default: {
const unreachable: never = item; // compile error if a kind is missing
return unreachable;
}
}
}Java 21 sealed interfaces with switch patterns, Kotlin when over sealed classes, Rust match and Python match with type checkers all offer equivalents. The compiler flags every unhandled case when a new kind is added, which is the main safety Visitor provides.
Prefer pattern matching when:
- The data types are plain records with no behavior of their own.
- The language checks exhaustiveness.
- You don't need visitors as swappable objects at runtime.
Keep a classic Visitor when the element types are classes in a hierarchy you can't turn into a union, or when visitors carry state and are passed around as values.
Related
- Composite: the tree that a Visitor usually walks.
- Iterator: separates traversal order from the visitor's per-node work.
- Switch Statements: a type switch that is repeated in many places is the smell Visitor or polymorphism addresses. One exhaustive switch in one place is not a smell.
- Classification of patterns: where Visitor sits among behavioral patterns.
See also
- VisitorAdd new operations over a fixed set of node types without editing those types, by routing each node to a type-specific method on a visitor object.
- CompositeTreat single objects and nested groups of them through one interface, so code can walk a tree of any depth without checking node types.
- Replace Conditional with PolymorphismReplace a switch on an object's type or kind with one class per case that overrides a shared method, so new cases add code instead of editing it.
- Switch StatementsThe same switch or if-chain over a type code is repeated across the codebase, so adding one case means editing every copy.
Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.