GUIDE · Behavioral
Interpreter
Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.
At a glance
- FIRST VISIT
- From Session 2.2, read the two tables in section 0 and stop. Run its code after Session 3.4; sections 1 and 2 are Unit 9.
- INTENT
- Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.
- PROBLEM
- Ops/users need to express rules ("price * qty - discount / 2", "tier == gold and region in EU") as DATA, not code — you need a tiny language, a tree for it, and an evaluator over a context.
- SAY
- Participants: AbstractExpression (interpret(context)), TerminalExpression (literal, variable), NonterminalExpression (add, mul, and, or — holds sub-expressions), Context (variable values), Client (parses/builds the tree). Pitch: "grammar rules become node classes; evaluating is walking the tree."
- SEEN IN
RegExp; template engines (Handlebars/Mustache, JSX → createElement); MongoDB / Prismawherequery objects; GraphQL-js & SQL parsers; rules engines & feature flags (JSON Logic, LaunchDarkly targeting);JSON.parse, math.js; CSS selector matching inquerySelector.- VS
- Visitor separates operations from the tree (add ops without touching nodes); Interpreter puts ONE operation (interpret) on every node. Composite is the structure; Interpreter is a Composite whose job is evaluating a language. Strategy picks an algorithm; Interpreter runs a sentence.
The idea#
Picture recipe cards clipped into a pile you can hold. A number card just says 3. A name card says "look up price on the cheat sheet." A minus card has a clip on the left and a clip on the right, and each clip holds another card. You do not bake "price times quantity, minus half the discount" into the cash register as a new function every time the specials change. You write the sentence down. You turn it into cards. You walk the cards.
An interpreter is that walk. You have a tiny language. You define a representation for its grammar, and you define an interpreter that uses the representation to run a sentence. Ops people need rules as data: a price formula, or "tier is gold and the region is in the EU." You need the language, a tree, and an evaluator that reads a context of variable values.
A terminal expression is a leaf: a literal, or a variable. A nonterminal expression holds smaller expressions: add, multiply, and, or. Every node has interpret(context). The client parses the sentence and builds the tree. Evaluating is walking it.
Why it matters#
The bug this pattern prevents is rules trapped in a deploy. If "free shipping over 100, or gold tier in the EU or the UK" is an if in the server, changing it means a release. If that sentence is data, an operator changes the sentence. Your code walks whatever tree it is given. The same tree with a new cheat sheet is a new answer, which is how one saved rule serves many orders.
You already run little languages. A regular expression is a sentence in a pattern language. Handlebars, Mustache, JSX, and JSON.parse turn text into a tree and walk it. A Mongo or Prisma where object is a query written as data. GraphQL, SQL, CSS selectors, and feature-flag rules such as JSON Logic are the same kind of sentence.
A visitor keeps the tree and lets you add operations without editing the nodes. An interpreter puts one operation, interpret, on every node. A composite is the tree shape. An interpreter is a composite whose job is to evaluate a language. A strategy picks an algorithm. An interpreter runs a sentence.
A small example#
function num(value) { return { interpret() { return value; } };} function name(key) { return { interpret(sheet) { if (!(key in sheet)) throw new Error('unbound ' + key); return sheet[key]; }, };} function minus(left, right) { return { interpret(sheet) { return left.interpret(sheet) - right.interpret(sheet); }, };} const sentence = minus(name('price'), num(2));const total = sentence.interpret({ price: 10 });sentence is three cards: a minus card holding a name card and a number card. total is 8. The cheat sheet supplied price. The minus card never looks up price itself. It asks the cards it holds.
What the code below is doing#
The sheet shows the same idea twice.
The first shape uses classes so the book names are visible. The grammar is the usual one. An expression is terms joined by + or -. A term is factors joined by * or /. A factor is a number, a variable, or a parenthesized expression. NumberExpr returns its value and ignores the context. VariableExpr reads the context and throws unbound variable when the name is missing. BinaryExpr holds an operator and two children, and interpret runs the matching function on the two child results. The operations are add, subtract, multiply, and divide.
The client is a tokenizer plus a small recursive-descent parser. Tokens are numbers, names, and the operator and parenthesis characters. factor, term, and expr build the tree. Multiplication lives in term, addition lives in expr, so precedence is the shape of the tree, not a special case inside interpret. Parsing price * qty - discount / 2 yields a minus node. With price 25, quantity 4, and discount 10, the walk returns 95. (price + 5) * qty is 120. 2 + 3 * 4 with an empty context is 14. The same minus tree with quantity 10 returns 245. price * tax parses, then throws at interpret time because tax is unbound. A missing close parenthesis throws a SyntaxError. Two numbers in a row throw on the unexpected second token.
The second shape is a JSON rule language, in the style of JSON Logic or a Mongo query, plus one recursive evaluator. A non-array node is a literal. An array is an operator followed by arguments. var walks a dotted path. and, or, and not combine booleans. == and > compare evaluated children. in evaluates only the first argument and treats the second as a literal list, not as a child expression. The free-shipping rule is data: total over 100, or gold tier and a region in EU or UK. An order of 150 in the US for a basic customer is free. An order of 20 in the EU for gold is free. An order of 20 in the US for gold is not. Negating a total of 20 in the US with an empty customer is true. The rule still works after a JSON round trip, which is what "storable" means. An unknown operator throws.
When you talk about it, say this: grammar rules become node classes, and evaluating is walking the tree.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Why this page is linked from CSS: nav a:hover#
// Session 2.2 wrote `nav a:hover`. A selector is a tiny language. The browser splits it into pieces,// builds a tree from them, and asks each element on the page "do you match?". That is the whole pattern.//// The four pieces of `nav a:hover` (the space is one of them), and the question each piece asks an element://// piece kind question asked of the element// nav type is your tag nav?// (space) descendant is there an element above you that answers yes to the piece on the left?// a type is your tag a?// :hover pseudo-class is the pointer over you right now?//// Three elements, asked in turn. One "no" anywhere means the rule does not apply://// element nav above? tag a? hovered? matches// an <a> inside <nav>, pointer over it yes yes yes yes// the same <a>, pointer elsewhere yes yes no no// a hovered <a> inside <body>, no <nav> no yes yes no//// Stop here on your first visit from Session 2.2; those two tables are the visit. Come back after Session 3.4 to// run the code below, which is the same four pieces and three elements in JavaScript. Sections 1 and 2 are the// interview shapes for Unit 9.const tokenizeSelector = (sel) => sel.match(/:[a-z]+|\s+|[a-z]+/g).map((t) => (/^\s+$/.test(t) ? ' ' : t)); // Three node kinds. Each answers interpret(element) with true or false; the Context is the element itself.const type = (name) => ({ interpret: (el) => el.tag === name }); // `nav`, `a`const pseudo = (inner, state) => ({ interpret: (el) => inner.interpret(el) && el[state] === true }); // `a:hover`const descendant = (ancestor, inner) => ({ // the space: "somewhere inside" interpret: (el) => inner.interpret(el) && el.ancestors.some((p) => ancestor.interpret(p)),});const navHover = descendant(type('nav'), pseudo(type('a'), 'hover')); // the tree for nav a:hover const navEl = { tag: 'nav', ancestors: [] };const hovered = { tag: 'a', hover: true, ancestors: [navEl] }; // <nav><a> with the pointer over itconst resting = { tag: 'a', hover: false, ancestors: [navEl] }; // the same link, pointer elsewhereconst loose = { tag: 'a', hover: true, ancestors: [{ tag: 'body', ancestors: [] }] }; // a hovered link outside any nav// The arithmetic example below is the same idea with numbers.Verified by 4 asserts
assert.deepEqual(tokenizeSelector('nav a:hover'), ['nav', ' ', 'a', ':hover']); // four pieces; the space is one of themassert.equal(navHover.interpret(hovered), true); // matches: in a nav, and hoveredassert.equal(navHover.interpret(resting), false); // no: not hoveredassert.equal(navHover.interpret(loose), false); // no: no nav above itClassic shape (classes, so the participant names are visible)#
// Grammar: expr := term (('+'|'-') term)* ; term := factor (('*'|'/') factor)* ; factor := NUM | VAR | '(' expr ')'class NumberExpr { // TerminalExpression constructor(v) { this.v = v; } interpret() { return this.v; }}class VariableExpr { // TerminalExpression — reads the Context constructor(name) { this.name = name; } interpret(ctx) { if (!(this.name in ctx)) throw new Error(`unbound variable: ${this.name}`); return ctx[this.name]; }}class BinaryExpr { // NonterminalExpression static ops = { '+': (a, b) => a + b, '-': (a, b) => a - b, '*': (a, b) => a * b, '/': (a, b) => a / b }; constructor(op, left, right) { Object.assign(this, { op, left, right }); } interpret(ctx) { return BinaryExpr.ops[this.op](this.left.interpret(ctx), this.right.interpret(ctx)); }} // Client: tokenizer + recursive-descent parser produce the expression treeconst tokenize = (src) => src.match(/\d+(?:\.\d+)?|[a-zA-Z_]\w*|[-+*/()]/g) ?? [];function parse(src) { const toks = tokenize(src); let i = 0; const peek = () => toks[i]; const next = () => toks[i++]; const factor = () => { const t = next(); if (t === '(') { const e = expr(); if (next() !== ')') throw new SyntaxError('expected )'); return e; } if (t === undefined) throw new SyntaxError('unexpected end'); return /^\d/.test(t) ? new NumberExpr(Number(t)) : new VariableExpr(t); }; const term = () => { let l = factor(); while (peek() === '*' || peek() === '/') l = new BinaryExpr(next(), l, factor()); return l; }; const expr = () => { let l = term(); while (peek() === '+' || peek() === '-') l = new BinaryExpr(next(), l, term()); return l; }; const tree = expr(); if (i !== toks.length) throw new SyntaxError(`unexpected token ${peek()}`); return tree;} const ast = parse('price * qty - discount / 2');const ctx = { price: 25, qty: 4, discount: 10 }; // ContextVerified by 9 asserts
assert.ok(ast instanceof BinaryExpr);assert.equal(ast.op, '-'); // precedence encoded in the tree shapeassert.equal(ast.interpret(ctx), 95); // (25*4) - (10/2)assert.equal(parse('(price + 5) * qty').interpret(ctx), 120);assert.equal(parse('2 + 3 * 4').interpret({}), 14);assert.equal(ast.interpret({ ...ctx, qty: 10 }), 245); // same tree, new contextassert.throws(() => parse('price * tax').interpret(ctx), /unbound variable: tax/);assert.throws(() => parse('(1 + 2'), SyntaxError);assert.throws(() => parse('1 2'), /unexpected token 2/);Idiomatic JS shape: a JSON rule language (JSON Logic / Mongo-query style) + one recursive evaluator#
const evaluate = (node, data) => { if (!Array.isArray(node)) return node; // literal const [op, ...args] = node; const v = (n) => evaluate(n, data); switch (op) { case 'var': return args[0].split('.').reduce((o, k) => o?.[k], data); case 'and': return args.every(v); case 'or': return args.some(v); case 'not': return !v(args[0]); case '==': return v(args[0]) === v(args[1]); case '>': return v(args[0]) > v(args[1]); case 'in': return args[1].includes(v(args[0])); // second arg is a literal list, not a sub-expression default: throw new Error(`unknown op ${op}`); }}; // The rule is DATA (DB row, flag config, admin UI) — change business rules without a deployconst freeShipping = ['or', ['>', ['var', 'order.total'], 100], ['and', ['==', ['var', 'customer.tier'], 'gold'], ['in', ['var', 'order.region'], ['EU', 'UK']]],];Verified by 6 asserts
assert.equal(evaluate(freeShipping, { order: { total: 150, region: 'US' }, customer: { tier: 'basic' } }), true);assert.equal(evaluate(freeShipping, { order: { total: 20, region: 'EU' }, customer: { tier: 'gold' } }), true);assert.equal(evaluate(freeShipping, { order: { total: 20, region: 'US' }, customer: { tier: 'gold' } }), false);assert.equal(evaluate(['not', freeShipping], { order: { total: 20, region: 'US' }, customer: {} }), true);assert.equal(evaluate(JSON.parse(JSON.stringify(freeShipping)), { order: { total: 101 }, customer: {} }), true); // storableassert.throws(() => evaluate(['between', 1, 2], {}), /unknown op/);See also