Aller au contenu principal

Pattern Visitor

Définissez de nouvelles opérations sur les éléments d'une structure sans modifier leurs types.


Le Problème

Vous construisez un éditeur d'emails. Chaque bloc (header, texte, image, bouton, séparateur) nécessite plusieurs rendus : aperçu visuel, export HTML, texte brut, audit d'accessibilité. L'approche naïve : une fonction géante avec des conditions imbriquées pour chaque combinaison type de bloc × rendu.

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}" />`;
// ... chaque bloc × chaque format
}
if (format === "text") {
if (block instanceof HeaderBlock) return block.text;
// ... encore
}
// ... encore
}

Chaque nouveau rendu oblige à toucher cette fonction. Chaque nouveau type de bloc oblige à ajouter des cas partout. Ça ne passe pas à l'échelle.


La Solution

Discriminated union + switch. Chaque "visitor" est simplement une fonction. TypeScript affine le type dans chaque branche du case et vérifie l'exhaustivité à la compilation.

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" : export HTML
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" : texte brut
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 "---";
}
};

// Mêmes données, différents "visitors"
const blocks: EmailBlock[] = [/* ... */];
blocks.map(toHtml); // chaînes HTML
blocks.map(toPlainText); // chaînes texte brut

Ajouter un nouveau rendu ? Écrivez une nouvelle fonction. Ajouter un nouveau type de bloc ? Ajoutez un variant à l'union, TypeScript signale chaque switch qui ne le gère pas.

Absorbé par le Langage

Cette solution n'utilise pas Pithos. C'est justement le point.

En TypeScript, les discriminated unions + switch sont le pattern Visitor. Eidos exporte une fonction @deprecated visit() qui n'existe que pour vous guider ici.


Démo

Un éditeur d'emails avec 5 types de blocs. Composez votre email, puis basculez entre 4 visitors : Preview (rendu visuel), HTML (code généré), Plain Text (les boutons deviennent [Click here: url]), et Accessibility Audit (signale les images sans texte alt). Mêmes données, rendu différent.

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 — utilisez les discriminated unions avec switch

Liens connexes