5 min read

Mastering the Visitor Pattern in TypeScript: From Theory to Real‑World Code

Learn how to implement the Visitor pattern in TypeScript, with a step‑by‑step AST example and practical tips for extensible, type‑safe code.
Mastering the Visitor Pattern in TypeScript: From Theory to Real‑World Code

Introduction

Design patterns are often presented as abstract concepts that feel distant from everyday coding. The Visitor pattern is no exception—its classic definition talks about “adding new operations to object structures without changing the structures.” In a strongly‑typed language like TypeScript, the pattern becomes a powerful tool for building extensible, type‑safe systems such as compilers, UI renderers, or analytics pipelines.

This article walks through the Visitor pattern from first principles, shows how to model it idiomatically in TypeScript, and demonstrates a complete, real‑world‑style example: an abstract syntax tree (AST) for a tiny expression language. By the end you’ll be able to add new behaviours (evaluation, pretty‑printing, type‑checking) without touching the node definitions themselves.


1. When (and Why) to Use a Visitor

Situation Typical Alternative Why Visitor Helps
You have a stable hierarchy of node types (e.g., AST, UI components) instanceof checks, switch on a kind field Centralises logic, avoids scattering if/else throughout the codebase
You need to add many unrelated operations (evaluate, render, lint) Add methods to each node class New operations are added in separate files, keeping the node definitions clean
You want compile‑time exhaustiveness – the compiler should warn you when a new node type is introduced but a visitor isn’t updated Dynamic dispatch via strings TypeScript’s discriminated unions and generics give you that safety

If your object graph is unlikely to change but you anticipate frequent new behaviours, the Visitor pattern is a strong candidate.


2. Core Concepts Refresher

  • Element (Node) – The object that accepts a visitor. It implements an accept(visitor) method.
  • Visitor – An interface (or abstract class) that declares a visitX(node: X) method for each concrete element type.
  • Double Dispatch – The combination of node.accept(visitor) and visitor.visitX(node) lets the runtime pick the correct method based on both the node’s concrete type and the visitor’s implementation.

In TypeScript we can express both sides with interfaces and generics, preserving static typing throughout.


3. Modeling the Node Hierarchy

We’ll start with a minimal expression language:

Expression ::= Literal | Binary
Literal    ::= number
Binary     ::= left: Expression, operator: '+' | '-', right: Expression

3.1 Discriminated Union for Nodes

// expression.ts
export type Expr =
  | Literal
  | Binary;

export interface Literal {
  kind: "Literal";
  value: number;
  accept<V>(visitor: Visitor<V>): V;
}

export interface Binary {
  kind: "Binary";
  left: Expr;
  operator: "+" | "-";
  right: Expr;
  accept<V>(visitor: Visitor<V>): V;
}

Notice the accept method is generic (<V>). It returns whatever the visitor decides—number, string, void, etc. This generic approach keeps the node definitions agnostic about the operation’s result type.

3.2 Implementing accept

// literal.ts
import { Visitor } from "./visitor";

export const makeLiteral = (value: number): Literal => ({
  kind: "Literal",
  value,
  accept<V>(visitor: Visitor<V>) {
    return visitor.visitLiteral(this);
  },
});

// binary.ts
import { Visitor } from "./visitor";

export const makeBinary = (
  left: Expr,
  operator: "+" | "-",
  right: Expr
): Binary => ({
  kind: "Binary",
  left,
  operator,
  right,
  accept<V>(visitor: Visitor<V>) {
    return visitor.visitBinary(this);
  },
});

Each node simply forwards itself to the appropriate visitor method. No switch or instanceof needed.


4. Defining the Visitor Interface

// visitor.ts
import { Literal, Binary } from "./expression";

export interface Visitor<R> {
  /** Evaluate a literal */
  visitLiteral(node: Literal): R;

  /** Evaluate a binary expression */
  visitBinary(node: Binary): R;
}

R is the return type of the operation. When we later create an evaluator we’ll instantiate Visitor<number>, while a pretty‑printer will be Visitor<string>.


5. Real‑World Example 1 – Evaluator

// evaluator.ts
import { Visitor } from "./visitor";
import { Expr } from "./expression";

export class Evaluator implements Visitor<number> {
  visitLiteral(node) {
    return node.value;
  }

  visitBinary(node) {
    const left = node.left.accept(this);
    const right = node.right.accept(this);
    return node.operator === "+" ? left + right : left - right;
  }
}

// usage
import { makeLiteral, makeBinary } from "./literal";
import { Evaluator } from "./evaluator";

const ast: Expr = makeBinary(
  makeLiteral(5),
  "+",
  makeBinary(makeLiteral(2), "-", makeLiteral(1))
);

const result = ast.accept(new Evaluator()); // → 6
console.log("Result:", result);

Key points

  • The evaluator never touches the node definitions beyond the accept contract.
  • Adding a new operator (e.g., *) only requires updating Binary and the evaluator—no changes elsewhere.

6. Real‑World Example 2 – Pretty Printer

// printer.ts
import { Visitor } from "./visitor";
import { Expr } from "./expression";

export class PrettyPrinter implements Visitor<string> {
  visitLiteral(node) {
    return node.value.toString();
  }

  visitBinary(node) {
    const left = node.left.accept(this);
    const right = node.right.accept(this);
    return `(${left} ${node.operator} ${right})`;
  }
}

// usage
import { makeLiteral, makeBinary } from "./literal";
import { PrettyPrinter } from "./printer";

const ast = makeBinary(makeLiteral(3), "-", makeLiteral(1));
console.log(ast.accept(new PrettyPrinter())); // "(3 - 1)"

The same AST can now be rendered as a string without any modification to the node classes.


7. Real‑World Example 3 – Type Checker (Returning a Custom Result)

Sometimes a visitor needs to return more than a primitive. Let’s create a CheckResult type.

// typechecker.ts
import { Visitor } from "./visitor";
import { Expr } from "./expression";

export type CheckResult = {
  ok: boolean;
  errors: string[];
};

export class TypeChecker implements Visitor<CheckResult> {
  visitLiteral(node) {
    return { ok: true, errors: [] };
  }

  visitBinary(node) {
    const left = node.left.accept(this);
    const right = node.right.accept(this);
    const errors = [...left.errors, ...right.errors];

    // In this tiny language all literals are numbers, so binary ops are always ok.
    // In a real language you would verify operand types here.
    return { ok: left.ok && right.ok, errors };
  }
}

Even though the return type is a complex object, the generic Visitor<R> handles it seamlessly.


8. Advanced TypeScript Techniques

8.1 Using Mapped Types to Auto‑Generate Visitor Methods

If the node hierarchy grows, manually writing each visitX method becomes tedious. TypeScript can infer the visitor shape from the union:

type VisitorFrom<T extends { kind: string }> = {
  [K in T["kind"]]: (node: Extract<T, { kind: K }>) => any;
};

Applied to our Expr:

type ExprVisitor = VisitorFrom<Expr>;

class GenericPrinter implements ExprVisitor {
  Literal(node) {
    return node.value.toString();
  }
  Binary(node) {
    const left = node.left.accept(this);
    const right = node.right.accept(this);
    return `(${left} ${node.operator} ${right})`;
  }
}

The compiler now enforces exhaustiveness: if you add a new node kind, TypeScript will emit an error until the visitor implements it.

8.2 Covariant Return Types with Overloads

Sometimes a visitor wants to return different types based on the node (e.g., number for literals, string for identifiers). Overloads can express this:

interface OverloadedVisitor {
  visitLiteral(node: Literal): number;
  visitIdentifier(node: Identifier): string;
  // fallback
  visit(node: Expr): unknown;
}

When you call node.accept(visitor), TypeScript will infer the correct overload based on the concrete node type.


9. Practical Tips & Common Pitfalls

Pitfall How to Avoid
Forgetting to update the visitor when a new node type is added Use the mapped‑type trick (VisitorFrom<T>) so the compiler forces you to implement the new method.
Returning any from visitor methods, losing type safety Keep the generic R and avoid any. If you need multiple return shapes, use overloads or discriminated unions.
Deep recursion causing stack overflow for huge trees Implement an iterative visitor using an explicit stack, or use tail‑call‑optimised environments (Node ≥ 14 with --stack-trace-limit).
Mixing mutable state inside visitors (e.g., accumulating results in a field) Prefer pure functions: let each visitX return its result, and compose them via accept. If you need shared context, pass a separate Context object.
Duplicating traversal logic across visitors Extract common traversal helpers (e.g., traverseChildren(node, visitor)) to keep each visitor focused on its own concern.

10. When Not to Use Visitor

  • Frequent changes to the node hierarchy – each change forces all visitors to be updated.
  • Simple hierarchies where a single operation suffices – a plain method on the node may be clearer.
  • Performance‑critical hot paths – the double dispatch adds a tiny overhead; in micro‑benchmarks a direct method call can be faster.

In those cases consider pattern matching with discriminated unions or strategy objects passed to the nodes.


11. Summary

The Visitor pattern shines in TypeScript when you have a stable set of node types and need to add many independent operations. By leveraging:

  • Generic accept<V> for flexible return types,
  • Discriminated unions for exhaustive type checking,
  • Mapped types to auto‑generate visitor signatures,

you can build clean, extensible systems that stay type‑safe throughout their evolution. The AST evaluator, pretty printer, and type checker examples illustrate how a single node hierarchy can serve multiple purposes without any duplication of logic.

Give the Visitor pattern a try in your next compiler, query engine, or UI renderer—your future self will thank you for the maintainability boost.