Interpreter Pattern
Define a grammar as composable functions and evaluate expressions by walking the structure.
The Problemβ
You need to parse Markdown into HTML. The naive approach: a pile of regex replacements.
function renderMarkdown(source: string): string {
let html = source;
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/`(.+?)`/g, "<code>$1</code>");
// ... 30 more regex, order matters, edge cases everywhere
return html;
}
This breaks the moment you have nested formatting (**bold *and italic***), code blocks that contain *asterisks*, or links inside headings. Each new feature adds more regex, more ordering bugs, more edge cases. It doesn't scale.
The Solutionβ
Separate the grammar from the evaluation. Define the AST as a discriminated union, then fold over it recursively.
// 1. Define the grammar as types
type MdNode =
| { type: "document"; children: MdNode[] }
| { type: "heading"; level: number; children: MdNode[] }
| { type: "paragraph"; children: MdNode[] }
| { type: "bold"; children: MdNode[] }
| { type: "italic"; children: MdNode[] }
| { type: "code-block"; lang: string; code: string }
| { type: "text"; value: string }
// ... as many node types as your grammar needs
// 2. Evaluate recursively: one switch, one function
function evalNode(node: MdNode): string {
switch (node.type) {
case "document":
return node.children.map(evalNode).join("\n");
case "heading":
return `<h${node.level}>${node.children.map(evalNode).join("")}</h${node.level}>`;
case "paragraph":
return `<p>${node.children.map(evalNode).join("")}</p>`;
case "bold":
return `<strong>${node.children.map(evalNode).join("")}</strong>`;
case "italic":
return `<em>${node.children.map(evalNode).join("")}</em>`;
case "code-block":
return `<pre><code>${escapeHtml(node.code)}</code></pre>`;
case "text":
return escapeHtml(node.value);
}
}
// 3. Pipeline: source β tokens β AST β HTML
const tokens = tokenize(source);
const ast = parse(tokens);
const html = evalNode(ast);
Adding a new node type? Add a variant to the union, add a case to the switch. TypeScript catches missing cases at compile time. Nesting works for free: evalNode calls itself.
This solution doesn't use Pithos. That's the point.
In TypeScript, discriminated unions + switch are the Interpreter pattern. Eidos exports a @deprecated interpret() function that exists only to guide you here, just like Taphos marks native APIs that replaced Arkhe utilities.
Live Demoβ
Type Markdown on the left, see the evaluated HTML on the right. Toggle to AST to see the tree the evaluator folds over.
- interpreter.ts
- Usage
/**
* Markdown Interpreter β evaluator (the Interpreter pattern).
*
* This file IS the pattern: a recursive fold over a discriminated union AST.
* No library needed β just a switch on the discriminant + recursion.
*/
import { tokenizeWithRefs, parse, type MdNode } from "./parsing-pipeline";
import { escape as escapeHtml } from "@pithos/core/arkhe/string/escape";
import { evalChildren, evalListItem, evalTable } from "./evalHelpers";
export type { MdNode } from "./parsing-pipeline";
// βββ Evaluator (AST β HTML) β the Interpreter pattern ββββββββββββββ
type EvalContext = { indent: number };
function evalNode(node: MdNode, ctx: EvalContext): string {
switch (node.type) {
case "document":
return node.children.map((c) => evalNode(c, ctx)).join("\n");
case "heading":
return `<h${node.level}>${evalChildren(node.children, ctx, evalNode)}</h${node.level}>`;
case "paragraph":
return `<p>${evalChildren(node.children, ctx, evalNode)}</p>`;
case "blockquote":
return `<blockquote>${node.children.map((c) => evalNode(c, ctx)).join("\n")}</blockquote>`;
case "code-block":
return `<pre><code class="language-${escapeHtml(node.lang)}">${escapeHtml(node.code)}</code></pre>`;
case "hr":
return "<hr />";
case "unordered-list":
return `<ul>${node.items.map((item) => evalListItem(item, ctx, evalNode)).join("")}</ul>`;
case "ordered-list":
return `<ol>${node.items.map((item) => evalListItem(item, ctx, evalNode)).join("")}</ol>`;
case "table":
return evalTable(node, ctx, evalNode);
case "text":
return escapeHtml(node.value);
case "bold":
return `<strong>${evalChildren(node.children, ctx, evalNode)}</strong>`;
case "italic":
return `<em>${evalChildren(node.children, ctx, evalNode)}</em>`;
case "bold-italic":
return `<strong><em>${evalChildren(node.children, ctx, evalNode)}</em></strong>`;
case "strikethrough":
return `<del>${evalChildren(node.children, ctx, evalNode)}</del>`;
case "inline-code":
return `<code>${escapeHtml(node.value)}</code>`;
case "link":
return `<a href="${escapeHtml(node.href)}" target="_blank" rel="noopener noreferrer">${evalChildren(node.children, ctx, evalNode)}</a>`;
case "autolink":
return `<a href="${escapeHtml(node.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(node.href)}</a>`;
case "image":
return `<img src="${escapeHtml(node.src)}" alt="${escapeHtml(node.alt)}" />`;
case "checkbox":
return node.checked
? `<input type="checkbox" checked disabled /> `
: `<input type="checkbox" disabled /> `;
case "line-break":
return "<br />";
case "html-block":
return node.content;
case "inline-html":
return node.content;
}
}
// βββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Full pipeline: source β tokens β AST β HTML */
export function renderMarkdown(source: string): { ast: MdNode; html: string } {
const { tokens, refLinks } = tokenizeWithRefs(source);
const ast = parse(tokens, refLinks);
const html = evalNode(ast, { indent: 0 });
return { ast, html };
}
import { useEffect } from "react";
import { FileText } from "lucide-react";
import { useMarkdownEditor } from "@/hooks/useMarkdownEditor";
import { useSyncScroll } from "@/hooks/useSyncScroll";
import { PREVIEW_STYLES } from "@/data/previewStyles";
import { PanelHeader } from "./PanelHeader";
import { AstTreeLine } from "./AstTreeLine";
import { TabBar } from "./TabBar";
import { RightPanelToggle } from "./RightPanelToggle";
export function MarkdownPreview() {
const { source, setSource, tab, setTab, rightPanel, setRightPanel, html, astLines } = useMarkdownEditor();
const { editorRef, previewRef, onEditorScroll, onPreviewScroll } = useSyncScroll();
useEffect(() => { editorRef.current?.focus(); }, [editorRef]);
const editorPanel = (
<div className="flex flex-col h-full min-h-0">
<PanelHeader icon={<FileText size={14} />} title="Markdown" />
<textarea ref={editorRef} value={source} onChange={(e) => setSource(e.target.value)} onScroll={onEditorScroll} spellCheck={false} className="flex-1 min-h-0 resize-none bg-transparent p-3 text-[13px] font-mono leading-relaxed text-zinc-300 placeholder-zinc-600 outline-none" placeholder="Type markdown here..." aria-label="Markdown editor" />
</div>
);
const previewContent = (
<div ref={previewRef} onScroll={onPreviewScroll} className="flex-1 min-h-0 overflow-auto p-4">
<style>{PREVIEW_STYLES}</style>
<div className="md-preview" dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
const astContent = (
<div className="flex-1 min-h-0 overflow-auto p-1">
{astLines.map((line, i) => <AstTreeLine key={i} line={line} />)}
</div>
);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.06] bg-white/[0.02] shrink-0">
<svg width="22" height="14" viewBox="0 0 208 128" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="198" height="118" rx="15" ry="15" fill="none" stroke="#f59e0b" strokeWidth="10" />
<path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39H30zm125 0l-30-33h20V30h20v35h20L155 98z" fill="#f59e0b" />
</svg>
<span className="text-sm font-semibold text-zinc-200">Markdown Interpreter</span>
<span className="text-[10px] text-zinc-600 ml-auto font-mono hidden sm:inline">tokenize β parse β evaluate</span>
</div>
{/* Mobile tabs */}
<TabBar active={tab} onChange={setTab} />
{/* Mobile: single panel */}
<div className="flex-1 min-h-0 sm:hidden">
{tab === "editor" && editorPanel}
{tab === "preview" && <div className="flex flex-col h-full min-h-0">{previewContent}</div>}
{tab === "ast" && <div className="flex flex-col h-full min-h-0">{astContent}</div>}
</div>
{/* Desktop: 2 columns */}
<div className="hidden sm:flex flex-1 min-h-0">
<div className="flex-1 min-w-0 border-r border-white/[0.06]">{editorPanel}</div>
<div className="flex-1 min-w-0 flex flex-col h-full">
<div className="flex items-center px-3 h-[42px] border-b border-white/[0.06] bg-white/[0.02] shrink-0">
<RightPanelToggle active={rightPanel} onChange={setRightPanel} />
</div>
{rightPanel === "preview" ? previewContent : astContent}
</div>
</div>
</div>
);
}
Beyond Markdownβ
The same pattern works for any grammar. Here's a query DSL:
type Query =
| { type: "select"; fields: string[] }
| { type: "where"; source: Query; predicate: (row: Row) => boolean }
| { type: "limit"; source: Query; count: number };
function execute(query: Query, data: Row[]): Row[] {
switch (query.type) {
case "select":
return data.map(row => pick(row, query.fields));
case "where":
return execute(query.source, data).filter(query.predicate);
case "limit":
return execute(query.source, data).slice(0, query.count);
}
}
Discriminated union for the grammar. Recursive function for the evaluation. That's the whole pattern.
APIβ
- interpreter
@deprecatedβ use discriminated unions with recursive evaluation
Related
- Eidos: Design Patterns Module All 23 GoF patterns reimagined for functional TypeScript
- Why FP over OOP? The philosophy behind Eidos: no classes, no inheritance, just functions and types
- Zygos Result Typed error handling for your evaluators and pipelines