Skip to content
aipatterns.dev

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

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

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

Dispatch 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

ChangeVisitorMethods on each node
Add an operationOne new visitor classEdit every node class
Add a node typeEdit every visitorOne 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:

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

  • 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

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.