Skip to main content

Visitor Pattern

Define new operations on a structure's elements without modifying their types.


The Problem​

You're building an email builder. Each block (header, text, image, button, divider) needs multiple renderings: visual preview, HTML export, plain text, accessibility audit. The naive approach: one giant function with nested conditionals for every combination of block type Γ— rendering.

function render(block: EmailBlock, format: "html" | "text" | "audit"): string {
if (format === "html") {
if (block instanceof HeaderBlock) return `<h1>${block.text}</h1>`;
if (block instanceof TextBlock) return `<p>${block.content}</p>`;
if (block instanceof ImageBlock) return `<img src="${block.src}" />`;
// ... every block Γ— every format
}
if (format === "text") {
if (block instanceof HeaderBlock) return block.text;
// ... again
}
// ... again
}

Every new rendering means touching this function. Every new block type means adding cases everywhere. It doesn't scale.


The Solution​

Discriminated union + switch. Each "visitor" is just a function. TypeScript narrows the type in each case branch and checks exhaustiveness at compile time.

type EmailBlock =
| { type: "header"; text: string; level: 1 | 2 | 3 }
| { type: "text"; content: string }
| { type: "image"; src: string; alt: string }
| { type: "button"; label: string; url: string }
| { type: "divider" };

// "Visitor 1": HTML export
const toHtml = (block: EmailBlock): string => {
switch (block.type) {
case "header": return `<h${block.level}>${block.text}</h${block.level}>`;
case "text": return `<p>${block.content}</p>`;
case "image": return `<img src="${block.src}" alt="${block.alt}" />`;
case "button": return `<a href="${block.url}">${block.label}</a>`;
case "divider": return `<hr />`;
}
};

// "Visitor 2": plain text
const toPlainText = (block: EmailBlock): string => {
switch (block.type) {
case "header": return block.text;
case "text": return block.content;
case "image": return block.alt ? `[Image: ${block.alt}]` : `[Image]`;
case "button": return `[${block.label}: ${block.url}]`;
case "divider": return "---";
}
};

// Same data, different "visitors"
const blocks: EmailBlock[] = [/* ... */];
blocks.map(toHtml); // HTML strings
blocks.map(toPlainText); // plain text strings

Adding a new rendering? Write a new function. Adding a new block type? Add a variant to the union, TypeScript flags every switch that doesn't handle it.

Absorbed by the Language

This solution doesn't use Pithos. That's the point.

In TypeScript, discriminated unions + switch are the Visitor pattern. Eidos exports a @deprecated visit() function that exists only to guide you here.


Live Demo​

An email builder with 5 block types. Compose your email, then switch between 4 visitors: Preview (visual rendering), HTML (generated code), Plain Text (buttons become [Click here: url]), and Accessibility Audit (flags images without alt text). Same data, different rendering.

Code
/**
* Visitor functions for the email block AST.
*
* Each visitor traverses the same discriminated union (EmailBlock)
* but produces a different output: HTML, plain text, or audit results.
*/

import type { EmailBlock, AuditResult } from "./types";

function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

/** Visitor 1: HTML export */
export function toHtml(block: EmailBlock): string {
switch (block.type) {
case "header":
return `<h${block.level}>${escapeHtml(block.text)}</h${block.level}>`;
case "text":
return `<p>${escapeHtml(block.content)}</p>`;
case "image":
return `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" />`;
case "button":
return `<a href="${escapeHtml(block.url)}" style="display:inline-block;padding:12px 24px;background:#1e293b;color:#fff;border-radius:6px;text-decoration:none">${escapeHtml(block.label)}</a>`;
case "divider":
return `<hr />`;
}
}

/** Visitor 2: Plain text export */
export function toPlainText(block: EmailBlock): string {
switch (block.type) {
case "header":
return block.level === 1
? `${"=".repeat(block.text.length)}\n${block.text}\n${"=".repeat(block.text.length)}`
: `${block.text}\n${"-".repeat(block.text.length)}`;
case "text":
return block.content;
case "image":
return block.alt ? `[Image: ${block.alt}]` : `[Image]`;
case "button":
return `[${block.label}: ${block.url}]`;
case "divider":
return "---";
}
}

/** Visitor 3: Accessibility audit */
export function audit(block: EmailBlock, index: number): AuditResult {
switch (block.type) {
case "header":
if (!block.text.trim()) return { block, index, severity: "error", message: "Empty heading" };
if (block.text.length > 80) return { block, index, severity: "warning", message: "Heading too long (>80 chars)" };
return { block, index, severity: "pass", message: "Heading OK" };
case "text":
if (!block.content.trim()) return { block, index, severity: "error", message: "Empty text block" };
return { block, index, severity: "pass", message: "Text OK" };
case "image":
if (!block.alt.trim()) return { block, index, severity: "error", message: "Missing alt text on image" };
return { block, index, severity: "pass", message: `Alt text: "${block.alt}"` };
case "button":
if (!block.label.trim()) return { block, index, severity: "error", message: "Button has no label" };
if (block.label.toLowerCase() === "click here") return { block, index, severity: "warning", message: "Vague button label (\"click here\")" };
return { block, index, severity: "pass", message: "Button OK" };
case "divider":
return { block, index, severity: "pass", message: "Decorative divider" };
}
}
Result

API​

  • visitor @deprecated β€” use discriminated unions with switch

Related