Mert Özen Further With Every Line
Software Design Design Patterns — #23

Visitor Pattern: Double Dispatch, ExpressionVisitor and a Comparison with Pattern Matching

Mert Özen Nov 7, 2026 12 min 3 views
Visitor Pattern: Double Dispatch, ExpressionVisitor and a Comparison with Pattern Matching

Visitor doubles C#'s one-dimensional virtual dispatch through Accept and Visit calls, letting you add new operations to a stable type hierarchy without touching the elements; the price is that a new type breaks every visitor. The static-type trap of overload resolution, the expression problem, ExpressionVisitor and Roslyn's CSharpSyntaxWalker, and a comparison with switch expressions that have no exhaustiveness check.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore's invoices and order summaries are made of a few element types: paragraph, table, image. The class that turns a document into HTML (HyperText Markup Language) has a separate overload for each type, plus a general overload for "an element I don't know":

DocumentElement[] invoice =
[
    new Paragraph("Order #1042"),
    new Table(["Title", "Qty"], [["Dune", "2"]]),
    new Image("logo.png", 120),
];

HtmlRenderer renderer = new();
foreach (DocumentElement element in invoice)
{
    Console.WriteLine(renderer.Render(element));
}

Console.WriteLine(renderer.Render(new Paragraph("Thank you")));

public abstract record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;

public sealed class HtmlRenderer
{
    public string Render(DocumentElement element) => "<!-- unknown element -->";
    public string Render(Paragraph paragraph) => $"<p>{paragraph.Text}</p>";
    public string Render(Table table) => $"<table>{table.Rows.Length} rows</table>";
    public string Render(Image image) => $"<img src=\"{image.Path}\">";
}

The loop renders the three elements one by one, and the last line passes a Paragraph directly. Four lines will appear on screen; which ones are <p> and which are <!-- unknown element -->?

Single Dispatch and the Limit of Overloads

Output:

<!-- unknown element -->
<!-- unknown element -->
<!-- unknown element -->
<p>Thank you</p>

All three elements in the loop came out "unknown"; the Paragraph outside the loop was rendered correctly. The reason is that C# picks overloads at compile time, based on the argument's static type. The loop variable is a DocumentElement; the compiler picks Render(DocumentElement) for that line and the fact that the element is actually a Table at runtime doesn't change that decision. On the last line the static type is already Paragraph.

The only mechanism that selects by runtime type is a virtual method call, and it works in one dimension only: when you write element.Something(), the runtime type of element decides which implementation runs. That's called single dispatch. What we want is a two-dimensional choice: both the element's type (paragraph, table, image) and the operation (HTML, plain text, word count). The language doesn't give us that; the Visitor pattern builds double dispatch by chaining two single dispatches.

The first fix that comes to mind is a chain of if (element is Table t) return Render(t); inside the general overload. It works, but you write the same chain again for every new operation, and any type you forget silently becomes "unknown".

Double Dispatch: Accept and Visit

GoF (the Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) describes Visitor's intent like this: represent an operation to be performed on the elements of an object structure; Visitor lets you define new operations without changing the classes of the elements. The structure has two parts. Each element type carries an Accept method that calls the visitor's Visit method for that type:

namespace Bookstore.Documents;

public interface IDocumentVisitor<out TResult>
{
    TResult VisitParagraph(Paragraph paragraph);
    TResult VisitTable(Table table);
    TResult VisitImage(Image image);
}

public abstract record DocumentElement
{
    public abstract TResult Accept<TResult>(IDocumentVisitor<TResult> visitor);
}

public sealed record Paragraph(string Text) : DocumentElement
{
    // Second dispatch: the type of this is known at compile time here
    public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitParagraph(this);
}

public sealed record Table(string[] Header, string[][] Rows) : DocumentElement
{
    public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitTable(this);
}

public sealed record Image(string Path, int WidthPx) : DocumentElement
{
    public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitImage(this);
}

And each operation becomes a visitor class:

namespace Bookstore.Documents;

public sealed class HtmlVisitor : IDocumentVisitor<string>
{
    public string VisitParagraph(Paragraph paragraph) => $"<p>{paragraph.Text}</p>";

    public string VisitTable(Table table) =>
        $"<table><tr>{string.Concat(table.Header.Select(h => $"<th>{h}</th>"))}</tr>" +
        string.Concat(table.Rows.Select(r => $"<tr>{string.Concat(r.Select(c => $"<td>{c}</td>"))}</tr>")) +
        "</table>";

    public string VisitImage(Image image) => $"<img src=\"{image.Path}\" width=\"{image.WidthPx}\">";
}

public sealed class PlainTextVisitor : IDocumentVisitor<string>
{
    public string VisitParagraph(Paragraph paragraph) => paragraph.Text;

    public string VisitTable(Table table) =>
        string.Join(Environment.NewLine, new[] { table.Header }.Concat(table.Rows).Select(r => string.Join(" | ", r)));

    public string VisitImage(Image image) => $"[image: {image.Path}]";
}

public sealed class WordCountVisitor : IDocumentVisitor<int>
{
    public int VisitParagraph(Paragraph paragraph) => Count(paragraph.Text);

    public int VisitTable(Table table) => table.Header.Concat(table.Rows.SelectMany(r => r)).Sum(Count);

    public int VisitImage(Image image) => 0;

    private static int Count(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
using Bookstore.Documents;

DocumentElement[] invoice =
[
    new Paragraph("Order #1042 for Ada Lovelace"),
    new Table(["Title", "Qty"], [["Dune", "2"], ["Emma", "1"]]),
    new Image("logo.png", 120),
];

HtmlVisitor html = new();
PlainTextVisitor text = new();
WordCountVisitor words = new();

foreach (DocumentElement element in invoice)
{
    Console.WriteLine(element.Accept(html));
}
Console.WriteLine();
Console.WriteLine(string.Join(Environment.NewLine, invoice.Select(e => e.Accept(text))));
Console.WriteLine();
Console.WriteLine($"Words: {invoice.Sum(e => e.Accept(words))}");
<p>Order #1042 for Ada Lovelace</p>
<table><tr><th>Title</th><th>Qty</th></tr><tr><td>Dune</td><td>2</td></tr><tr><td>Emma</td><td>1</td></tr></table>
<img src="logo.png" width="120">

Order #1042 for Ada Lovelace
Title | Qty
Dune | 2
Emma | 1
[image: logo.png]

Words: 11

The call element.Accept(html) makes two choices in a row. The first is virtual dispatch: since Accept is abstract, the runtime type of element (Table) runs its own implementation. The second happens inside that implementation: on the line visitor.VisitTable(this), the type of this is known for certain at compile time to be Table, and the runtime type of visitor (HtmlVisitor) is again picked by virtual dispatch through the interface. The information lost in Guess First comes back through the Accept line written once inside each element.

The generic TResult lets operations return different result types: the HTML and plain-text visitors return string, the word counter returns int. In GoF's original examples the Visit methods return void and accumulate results inside the visitor; in C#, generics usually make that accumulation unnecessary. A real application would have a PdfVisitor with the same shape; since a PDF library would bloat the example, the plain-text visitor stands in for it here. As you can see, adding an output format means writing a new class without touching the elements.

The Expression Problem: A New Type or a New Operation?

Now let's try the opposite. We want to add a quote element to documents. We add the line TResult VisitQuote(Quote quote); to the interface and write the new type:

using Bookstore.Documents;

Console.WriteLine(new Quote("Dune is a masterpiece.", "Grace").Accept(new HtmlVisitor()));

// New element type: VisitQuote was added to the interface, Accept calls it
public sealed record Quote(string Text, string Author) : DocumentElement
{
    public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitQuote(this);
}
Visitors.cs(15,40): error CS0535: 'PlainTextVisitor' does not implement interface member 'IDocumentVisitor<string>.VisitQuote(Quote)'
Visitors.cs(25,40): error CS0535: 'WordCountVisitor' does not implement interface member 'IDocumentVisitor<int>.VisitQuote(Quote)'
Visitors.cs(3,35): error CS0535: 'HtmlVisitor' does not implement interface member 'IDocumentVisitor<string>.VisitQuote(Quote)'

The build stops with three errors: every visitor broke because it doesn't know the new element. This is the price Visitor pays on purpose. In 1998 Philip Wadler named this dilemma "the expression problem" on a mailing list: in a data type hierarchy we want to add both new types and new operations, but most classic designs make only one of them cheap.

DesignAdding an operationAdding a type
Virtual method (each operation inside the element)A new method in every element classOne new class
VisitorOne new visitor classA new method in the interface and an implementation in every visitor
switch expression and type patternsOne new functionA new arm in every switch

In a way, Visitor's error is good news: the compiler shows you every place the new type isn't handled. Which design you choose depends on what changes more often in your hierarchy. If document elements change once a year and new output formats arrive often, Visitor; if element types are added often and operations are stable, virtual methods are cheaper.

Visitor in .NET: ExpressionVisitor

.NET's most widely used visitor is System.Linq.Expressions.ExpressionVisitor. An Expression<Func<...>> is a tree that LINQ (Language Integrated Query) providers translate to SQL (Structured Query Language); EF (Entity Framework) Core's query translation is made of visitors that walk and rewrite that tree from start to finish. A small visitor that finds which properties a filter touches:

using System.Linq.Expressions;

Expression<Func<Book, bool>> filter = b => b.Price > 20m && b.Author.Name == "Ada" || b.Stock == 0;

PropertyCollector collector = new();
collector.Visit(filter);
Console.WriteLine(filter.Body);
Console.WriteLine(string.Join(", ", collector.Properties));

public sealed record Author(string Name);
public sealed record Book(string Title, decimal Price, int Stock, Author Author);

public sealed class PropertyCollector : ExpressionVisitor
{
    public SortedSet<string> Properties { get; } = [];

    protected override Expression VisitMember(MemberExpression node)
    {
        Properties.Add($"{node.Member.DeclaringType!.Name}.{node.Member.Name}");
        // Call base to keep walking the child nodes
        return base.VisitMember(node);
    }
}
(((b.Price > 20) AndAlso (b.Author.Name == "Ada")) OrElse (b.Stock == 0))
Author.Name, Book.Author, Book.Price, Book.Stock

We only overrode VisitMember; the base class walked the rest of the tree (AndAlso, OrElse, comparisons, constants). b.Author.Name is two member accesses, so it produced two entries. Look at the dotnet/runtime source and you'll see the very same double dispatch: ExpressionVisitor.Visit(node) just calls node.Accept(this), and BinaryExpression.Accept returns visitor.VisitBinary(this). Here the visitor is used not just for reading but for rewriting: Visit methods return an Expression, and returning a different node changes that part of the tree. Two posts from now, in the Specification pattern, we'll need that ability to combine two filters. The Advanced C# series post "Expression Trees in C#: Holding a Lambda as Data, and the Bridge to EF Core" covers the tree itself.

Roslyn: CSharpSyntaxWalker

Roslyn, the C# compiler, offers the same pattern for walking syntax trees. CSharpSyntaxWalker is a visitor with a Visit... method for every syntax node type; you override the type you care about and leave the rest to the base class. A walker that catches the Long Parameter List smell from the Clean Code series post "A Code Smell Catalog: Recognizing Smells in C# Code":

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

const string source = """
    public sealed class OrderService
    {
        public void Place(Order order, Customer customer) { }
        public decimal Total(Order order) => 0m;
        private static void Log(string message, int level, bool urgent, string tag, object data) { }
    }
    """;

SyntaxTree tree = CSharpSyntaxTree.ParseText(source);
LongParameterListWalker walker = new(maxParameters: 3);
walker.Visit(tree.GetRoot());

public sealed class LongParameterListWalker(int maxParameters) : CSharpSyntaxWalker
{
    public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
    {
        int count = node.ParameterList.Parameters.Count;
        string flag = count > maxParameters ? "  <-- too many" : "";
        Console.WriteLine($"{node.Identifier.Text}({count}){flag}");
        base.VisitMethodDeclaration(node);
    }
}
Place(2)
Total(1)
Log(5)  <-- too many

Analyzers and source generators are more elaborate versions of these walkers. Roslyn has hundreds of syntax node types; writing a switch over them isn't practical, while a visitor reduces "I only care about method declarations" to a single override. This is exactly where Visitor works best: the type hierarchy is large, stable and controlled by someone else, and the operations are yours.

The Modern Alternative: switch Expressions and Type Patterns

The switch expressions and type patterns introduced in C# 8 fit the two-dimensional choice that Visitor solves into a single function. No Accept on the elements, no interface:

DocumentElement[] invoice =
[
    new Paragraph("Order #1042"),
    new Image("logo.png", 120),
    new Quote("Dune is a masterpiece.", "Grace"),
];

foreach (DocumentElement element in invoice)
{
    Console.WriteLine(Html.Render(element));
}

public abstract record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
public sealed record Quote(string Text, string Author) : DocumentElement;

public static class Html
{
    public static string Render(DocumentElement element) => element switch
    {
        Paragraph p => $"<p>{p.Text}</p>",
        Table t => $"<table>{t.Rows.Length} rows</table>",
        Image { WidthPx: > 600 } i => $"<img src=\"{i.Path}\" class=\"wide\">",
        Image i => $"<img src=\"{i.Path}\" width=\"{i.WidthPx}\">",
    };
}
Program.cs(21,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.
<p>Order #1042</p>
<img src="logo.png" width="120">
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Quote { Text = Dune is a masterpiece., Author = Grace }.

The brevity is obvious: four arms, a separate rule for wide images through a property pattern, zero ceremony. In Visitor, the same rule would need an if inside VisitImage. But the price is in the output: Quote was added to the hierarchy but not to the switch, and the error came not at compile time but at runtime as a SwitchExpressionException. The compiler warned with CS8509, but what it says isn't "Quote is missing", it's "the pattern _ is missing". DocumentElement is an abstract record and the compiler can't know that no other assembly will derive from it; since you can't express that the hierarchy is closed, there is no exhaustiveness check either.

So in practice there are two paths. If the hierarchy is under your control and the operations are few, a switch expression reads better; adding an explicit arm like _ => throw new UnreachableException() to silence the warning at least tells you where the failure is. If you want every operation to break at compile time when a new type is added, Visitor is the only structure that gives that guarantee today. The union types and closed hierarchies coming with C# 15 aim to let you tell the compiler a hierarchy is closed and bring exhaustiveness checks to switch; we'll cover them in the last post of the series. The Advanced C# series post "Pattern Matching in C# in Depth: Property, Positional and Tuple Patterns" covers the pattern syntax.

Common Pitfalls

Visitor on an open hierarchy. If element types are added often, or other teams or plugins can add new types, every addition breaks every visitor. And you can't update a visitor someone else wrote. Visitor is built on the assumption that the type hierarchy is stable; without that assumption the pattern's cost exceeds its benefit.

Accumulating state in a visitor and reusing it. If a GoF-style visitor returning void collects its result in a field, reusing the same instance for a second document carries the first document's result too. Returning the result as TResult, or creating a new instance for every use, prevents that bug.

Breaking encapsulation for the visitor. A visitor needs the element's internal data, so elements get public properties just for visitors and gradually everything leaks out. The behavioral part of an element should stay in the element; expose to the visitor only data that's fine to show the outside world.

Forgetting the base.Visit... call. In ExpressionVisitor and CSharpSyntaxWalker, if your override doesn't call base, the subtree under that node is never walked. Delete base.VisitMember in PropertyCollector and for b.Author.Name you'll only see Author.Name; Book.Author disappears.

When Not to Use It

When the hierarchy is small and there's a single operation. Writing a Visitor for three element types and only HTML output means an interface, three Accept methods and a visitor class. A virtual method or a single switch expression does the same job.

When new types arrive more often than new operations. The first row of the expression problem table is for that case: putting behavior in the element itself reduces adding a type to a single class.

When the type hierarchy isn't yours and you can't add Accept. Visitor needs the elements' cooperation. If you have to walk a third-party library's types and the library offers no visitor, a switch expression with type patterns is the only realistic option.

A Small Experiment

★☆☆ Run the HtmlRenderer from Guess First with the loop variable cast to dynamic (renderer.Render((dynamic)element)). How did the output change? Why does this move overload selection to runtime, and what does it cost?

★★☆ Complete the Quote element in the Visitor design: add VisitQuote to all three visitors and use <blockquote> in HTML. Then add the same element to the switch expression version and count how many files you touched in each.

★★★ Write a PriceInflationVisitor deriving from ExpressionVisitor that raises every decimal constant in a filter by 10 percent (override VisitConstant). Compile() the new tree, run it over a few books and compare the results with the original filter.

With Visitor we've reached the end of the behavioral patterns and seen all 23 patterns in the GoF catalog. The next post looks at the catalog's dark side: God Object, Service Locator, Singleton abuse, Anemic Domain Model, and the patternitis that puts five patterns on a three-line job. The symptoms of anti-patterns, why they appear and how to transform them.