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.

lesson3 sectionsruns in browserBehavioral19 asserts147 lines

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 / Prisma where query objects; GraphQL-js & SQL parsers; rules engines & feature flags (JSON Logic, LaunchDarkly targeting); JSON.parse, math.js; CSS selector matching in querySelector.
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#

JS
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.

JSsystem-design/design-patterns/behavioral/interpreter.js · setup
'use strict';const assert = require('node:assert/strict');
00

Why this page is linked from CSS: nav a:hover#

JS
// 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
JS
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 it
01

Classic shape (classes, so the participant names are visible)#

JS
// 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 };          // Context
Verified by 9 asserts
JS
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/);
02

Idiomatic JS shape: a JSON rule language (JSON Logic / Mongo-query style) + one recursive evaluator#

JS
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
JS
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