Interpreter
Interpreter represents each rule of a small language as a class, so any sentence becomes a tree of objects that can evaluate itself. This page gives Pedal's marketing team a tiny promo-rule language, with a parser, clear errors and a live editor.
1. The pain
Pedal's marketing team runs promotions: "weekend rides over 20 minutes get £1 off", "late-night rides are free for members". Every rule was a small function a developer wrote, reviewed and deployed: def promo_17(ride): return ride.weekend and ride.minutes > 20.
Marketing wanted to try a new rule every week and waited days each time. The rules themselves were always the same handful of ideas combined with "and", "or" and "not": weekend, member, minutes, hour of day. A tiny language for exactly those would let marketing write the rules, with the code checking them.
2. The idea in one picture
Interpreter represents each rule of a small language as a class, builds a tree of those objects for each sentence, and evaluates the tree. Here the grammar has five kinds of expression: Flag (weekend, member), Compare (minutes > 20), Not, And, Or. Each node evaluates itself, asking its children first. A small parser turns marketing's text into the tree.
The pattern is about the tree and its evaluation; the parser is a separate job (this page writes a short recursive-descent one). It suits small, stable languages. For anything bigger, use a parser generator or an existing rules engine, and keep the language closed: no loops, no access to anything but the ride.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Abstract expression | The interface every node implements. | Expr.evaluate(ride) |
| Terminal expression | A leaf: a basic fact about the ride. | Flag(weekend), Compare(minutes > 20) |
| Non-terminal expression | Combines child expressions. | And, Or, Not |
| Context | The data expressions are evaluated against. | The ride: weekend, member, minutes, hour |
| Client (and parser) | Builds the tree from text and evaluates it. | Parser(text).parse(), then tree.evaluate(ride) |
4. Before and after
Promo rules both ways (Python sketch; the full program is in section 5).
Without the pattern
def promo_17(ride):
return ride.weekend and ride.minutes > 20
def promo_18(ride):
return ride.member or (
ride.hour >= 22 and ride.minutes < 30)
# promo_19: ticket raised, waiting
# for the next deployWith it
rule = Parser("member or (hour >= 22 "
"and minutes < 30)").parse()
# Or(Flag(member),
# And(Compare(hour >= 22),
# Compare(minutes < 30)))
rule.evaluate(ride) # True or False
# promo_19: typed into the admin screen| The next change | Without the pattern | With it |
|---|---|---|
| Marketing wants a new rule | A developer writes, reviews and deploys a function | Marketing types it; the parser checks it |
| A typo in a rule | Caught in code review, if at all | A clear error: "unknown name 'speed'" |
| Add "station" as something rules can test | Nothing to change for old rules, but no new rules without code | One new terminal expression |
Rules become data that non-developers can write, inside a language small enough to check completely.
5. The code
Five expression classes and a short recursive-descent parser. Three rules are parsed, printed as trees and evaluated against three rides; two broken rules are rejected with clear errors.
from __future__ import annotations
import re
from abc import ABC, abstractmethod
Ride = dict[str, int | bool]
NUMBERS, FLAGS = {"minutes", "hour"}, {"weekend", "member"}
# ---- The grammar as classes: each kind of expression knows how to evaluate itself. ----
class Expr(ABC):
@abstractmethod
def evaluate(self, ride: Ride) -> bool: ...
@abstractmethod
def describe(self) -> str: ...
class Flag(Expr):
def __init__(self, name: str) -> None: self.name = name
def evaluate(self, ride: Ride) -> bool: return bool(ride[self.name])
def describe(self) -> str: return f"Flag({self.name})"
class Compare(Expr):
OPS = {">": lambda a, b: a > b, ">=": lambda a, b: a >= b, "<": lambda a, b: a < b, "<=": lambda a, b: a <= b, "==": lambda a, b: a == b}
def __init__(self, name: str, op: str, value: int) -> None: self.name, self.op, self.value = name, op, value
def evaluate(self, ride: Ride) -> bool: return bool(Compare.OPS[self.op](int(ride[self.name]), self.value))
def describe(self) -> str: return f"Compare({self.name} {self.op} {self.value})"
class Not(Expr):
def __init__(self, inner: Expr) -> None: self.inner = inner
def evaluate(self, ride: Ride) -> bool: return not self.inner.evaluate(ride)
def describe(self) -> str: return f"Not({self.inner.describe()})"
class And(Expr):
def __init__(self, left: Expr, right: Expr) -> None: self.left, self.right = left, right
def evaluate(self, ride: Ride) -> bool: return self.left.evaluate(ride) and self.right.evaluate(ride)
def describe(self) -> str: return f"And({self.left.describe()}, {self.right.describe()})"
class Or(Expr):
def __init__(self, left: Expr, right: Expr) -> None: self.left, self.right = left, right
def evaluate(self, ride: Ride) -> bool: return self.left.evaluate(ride) or self.right.evaluate(ride)
def describe(self) -> str: return f"Or({self.left.describe()}, {self.right.describe()})"
# ---- A small recursive-descent parser turns text into that tree. ----
class RuleError(Exception):
pass
class Parser:
def __init__(self, text: str) -> None:
self.tokens = re.findall(r">=|<=|==|[()<>]|\w+", text)
self.pos = 0
def peek(self) -> str | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def take(self) -> str:
tok = self.peek()
if tok is None:
raise RuleError("expected a condition, found the end of the rule")
self.pos += 1
return tok
def parse(self) -> Expr:
expr = self.rule()
if self.peek() is not None:
raise RuleError(f"unexpected '{self.peek()}'")
return expr
def rule(self) -> Expr: # rule := term ("or" term)*
expr = self.term()
while self.peek() == "or":
self.take()
expr = Or(expr, self.term())
return expr
def term(self) -> Expr: # term := factor ("and" factor)*
expr = self.factor()
while self.peek() == "and":
self.take()
expr = And(expr, self.factor())
return expr
def factor(self) -> Expr: # factor := "not" factor | "(" rule ")" | flag | name op number
tok = self.take()
if tok == "not":
return Not(self.factor())
if tok == "(":
expr = self.rule()
if self.take() != ")":
raise RuleError("expected ')'")
return expr
if tok in FLAGS:
return Flag(tok)
if tok in NUMBERS:
op = self.take()
if op not in Compare.OPS:
raise RuleError(f"expected a comparison after '{tok}'")
num = self.peek()
if num is None or not num.isdigit():
raise RuleError(f"expected a number after '{op}'")
self.take()
return Compare(tok, op, int(num))
raise RuleError(f"unknown name '{tok}'")
if __name__ == "__main__":
rides: dict[str, Ride] = {
"A": {"weekend": True, "member": True, "minutes": 25, "hour": 14},
"B": {"weekend": True, "member": False, "minutes": 15, "hour": 23},
"C": {"weekend": False, "member": False, "minutes": 40, "hour": 22},
}
for text in ["weekend and minutes > 20", "member or (hour >= 22 and minutes < 30)", "not member and weekend", "weekend and", "speed > 3"]:
print(f"rule: {text}")
try:
tree = Parser(text).parse()
except RuleError as e:
print(f" error: {e}")
continue
print(f" tree: {tree.describe()}")
hits = [name for name, ride in rides.items() if tree.evaluate(ride)]
print(f" applies to: {', '.join(hits) or 'none'}")
type Ride = Record<string, number | boolean>;
const NUMBERS = new Set(["minutes", "hour"]), FLAGS = new Set(["weekend", "member"]);
// ---- The grammar as classes: each kind of expression knows how to evaluate itself. ----
interface Expr { evaluate(ride: Ride): boolean; describe(): string; }
class Flag implements Expr {
constructor(private readonly name: string) {}
evaluate(ride: Ride) { return Boolean(ride[this.name]); }
describe() { return `Flag(${this.name})`; }
}
const OPS: Record<string, (a: number, b: number) => boolean> = { ">": (a, b) => a > b, ">=": (a, b) => a >= b, "<": (a, b) => a < b, "<=": (a, b) => a <= b, "==": (a, b) => a === b };
class Compare implements Expr {
constructor(private readonly name: string, private readonly op: string, private readonly value: number) {}
evaluate(ride: Ride) { return OPS[this.op](Number(ride[this.name]), this.value); }
describe() { return `Compare(${this.name} ${this.op} ${this.value})`; }
}
class Not implements Expr {
constructor(private readonly inner: Expr) {}
evaluate(ride: Ride) { return !this.inner.evaluate(ride); }
describe() { return `Not(${this.inner.describe()})`; }
}
class And implements Expr {
constructor(private readonly left: Expr, private readonly right: Expr) {}
evaluate(ride: Ride) { return this.left.evaluate(ride) && this.right.evaluate(ride); }
describe() { return `And(${this.left.describe()}, ${this.right.describe()})`; }
}
class Or implements Expr {
constructor(private readonly left: Expr, private readonly right: Expr) {}
evaluate(ride: Ride) { return this.left.evaluate(ride) || this.right.evaluate(ride); }
describe() { return `Or(${this.left.describe()}, ${this.right.describe()})`; }
}
// ---- A small recursive-descent parser turns text into that tree. ----
class RuleError extends Error {}
class Parser {
private readonly tokens: string[];
private pos = 0;
constructor(text: string) { this.tokens = text.match(/>=|<=|==|[()<>]|\w+/g) ?? []; }
private peek(): string | null { return this.pos < this.tokens.length ? this.tokens[this.pos] : null; }
private take(): string {
const tok = this.peek();
if (tok === null) throw new RuleError("expected a condition, found the end of the rule");
this.pos++;
return tok;
}
parse(): Expr {
const expr = this.rule();
if (this.peek() !== null) throw new RuleError(`unexpected '${this.peek()}'`);
return expr;
}
private rule(): Expr { // rule := term ("or" term)*
let expr = this.term();
while (this.peek() === "or") { this.take(); expr = new Or(expr, this.term()); }
return expr;
}
private term(): Expr { // term := factor ("and" factor)*
let expr = this.factor();
while (this.peek() === "and") { this.take(); expr = new And(expr, this.factor()); }
return expr;
}
private factor(): Expr { // factor := "not" factor | "(" rule ")" | flag | name op number
const tok = this.take();
if (tok === "not") return new Not(this.factor());
if (tok === "(") {
const expr = this.rule();
if (this.take() !== ")") throw new RuleError("expected ')'");
return expr;
}
if (FLAGS.has(tok)) return new Flag(tok);
if (NUMBERS.has(tok)) {
const op = this.take();
if (!(op in OPS)) throw new RuleError(`expected a comparison after '${tok}'`);
const num = this.peek();
if (num === null || !/^\d+$/.test(num)) throw new RuleError(`expected a number after '${op}'`);
this.take();
return new Compare(tok, op, Number(num));
}
throw new RuleError(`unknown name '${tok}'`);
}
}
const rides: Record<string, Ride> = {
A: { weekend: true, member: true, minutes: 25, hour: 14 },
B: { weekend: true, member: false, minutes: 15, hour: 23 },
C: { weekend: false, member: false, minutes: 40, hour: 22 },
};
for (const text of ["weekend and minutes > 20", "member or (hour >= 22 and minutes < 30)", "not member and weekend", "weekend and", "speed > 3"]) {
console.log(`rule: ${text}`);
let tree: Expr;
try {
tree = new Parser(text).parse();
} catch (e) {
if (!(e instanceof RuleError)) throw e;
console.log(` error: ${e.message}`);
continue;
}
console.log(` tree: ${tree.describe()}`);
const hits = Object.keys(rides).filter((name) => tree.evaluate(rides[name]));
console.log(` applies to: ${hits.join(", ") || "none"}`);
}import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiPredicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Main {
static final Set<String> NUMBERS = Set.of("minutes", "hour"), FLAGS = Set.of("weekend", "member");
static final Map<String, BiPredicate<Integer, Integer>> OPS = Map.of(">", (a, b) -> a > b, ">=", (a, b) -> a >= b, "<", (a, b) -> a < b, "<=", (a, b) -> a <= b, "==", (a, b) -> a.equals(b));
// ---- The grammar as classes: each kind of expression knows how to evaluate itself. ----
interface Expr { boolean evaluate(Map<String, Object> ride); String describe(); }
record Flag(String name) implements Expr {
public boolean evaluate(Map<String, Object> ride) { return (Boolean) ride.get(name); }
public String describe() { return "Flag(" + name + ")"; }
}
record Compare(String name, String op, int value) implements Expr {
public boolean evaluate(Map<String, Object> ride) { return OPS.get(op).test((Integer) ride.get(name), value); }
public String describe() { return "Compare(" + name + " " + op + " " + value + ")"; }
}
record Not(Expr inner) implements Expr {
public boolean evaluate(Map<String, Object> ride) { return !inner.evaluate(ride); }
public String describe() { return "Not(" + inner.describe() + ")"; }
}
record And(Expr left, Expr right) implements Expr {
public boolean evaluate(Map<String, Object> ride) { return left.evaluate(ride) && right.evaluate(ride); }
public String describe() { return "And(" + left.describe() + ", " + right.describe() + ")"; }
}
record Or(Expr left, Expr right) implements Expr {
public boolean evaluate(Map<String, Object> ride) { return left.evaluate(ride) || right.evaluate(ride); }
public String describe() { return "Or(" + left.describe() + ", " + right.describe() + ")"; }
}
// ---- A small recursive-descent parser turns text into that tree. ----
static class RuleError extends RuntimeException {
private static final long serialVersionUID = 1L;
RuleError(String message) { super(message); }
}
static class Parser {
private final List<String> tokens = new ArrayList<>();
private int pos;
Parser(String text) {
Matcher m = Pattern.compile(">=|<=|==|[()<>]|\\w+").matcher(text);
while (m.find()) tokens.add(m.group());
}
private String peek() { return pos < tokens.size() ? tokens.get(pos) : null; }
private String take() {
String tok = peek();
if (tok == null) throw new RuleError("expected a condition, found the end of the rule");
pos++;
return tok;
}
Expr parse() {
Expr expr = rule();
if (peek() != null) throw new RuleError("unexpected '" + peek() + "'");
return expr;
}
private Expr rule() { // rule := term ("or" term)*
Expr expr = term();
while ("or".equals(peek())) { take(); expr = new Or(expr, term()); }
return expr;
}
private Expr term() { // term := factor ("and" factor)*
Expr expr = factor();
while ("and".equals(peek())) { take(); expr = new And(expr, factor()); }
return expr;
}
private Expr factor() { // factor := "not" factor | "(" rule ")" | flag | name op number
String tok = take();
if (tok.equals("not")) return new Not(factor());
if (tok.equals("(")) {
Expr expr = rule();
if (!take().equals(")")) throw new RuleError("expected ')'");
return expr;
}
if (FLAGS.contains(tok)) return new Flag(tok);
if (NUMBERS.contains(tok)) {
String op = take();
if (!OPS.containsKey(op)) throw new RuleError("expected a comparison after '" + tok + "'");
String num = peek();
if (num == null || !num.matches("\\d+")) throw new RuleError("expected a number after '" + op + "'");
take();
return new Compare(tok, op, Integer.parseInt(num));
}
throw new RuleError("unknown name '" + tok + "'");
}
}
static Map<String, Object> ride(boolean weekend, boolean member, int minutes, int hour) {
return Map.of("weekend", weekend, "member", member, "minutes", minutes, "hour", hour);
}
public static void main(String[] args) {
Map<String, Map<String, Object>> rides = new LinkedHashMap<>();
rides.put("A", ride(true, true, 25, 14));
rides.put("B", ride(true, false, 15, 23));
rides.put("C", ride(false, false, 40, 22));
for (String text : List.of("weekend and minutes > 20", "member or (hour >= 22 and minutes < 30)", "not member and weekend", "weekend and", "speed > 3")) {
System.out.println("rule: " + text);
Expr tree;
try {
tree = new Parser(text).parse();
} catch (RuleError e) {
System.out.println(" error: " + e.getMessage());
continue;
}
System.out.println(" tree: " + tree.describe());
List<String> hits = new ArrayList<>();
for (Map.Entry<String, Map<String, Object>> r : rides.entrySet()) if (tree.evaluate(r.getValue())) hits.add(r.getKey());
System.out.println(" applies to: " + (hits.isEmpty() ? "none" : String.join(", ", hits)));
}
}
}Output (the same in all three languages)
rule: weekend and minutes > 20 tree: And(Flag(weekend), Compare(minutes > 20)) applies to: A rule: member or (hour >= 22 and minutes < 30) tree: Or(Flag(member), And(Compare(hour >= 22), Compare(minutes < 30))) applies to: A, B rule: not member and weekend tree: And(Not(Flag(member)), Flag(weekend)) applies to: B rule: weekend and error: expected a condition, found the end of the rule rule: speed > 3 error: unknown name 'speed'
6. Try it
Write a promo rule
Names: weekend, member, minutes, hour. Words: and, or, not, brackets, and > >= < <= == with whole numbers.
7. When not to use it
What you get
- Rules as data: written, stored and changed without a deploy.
- Safe by construction: the language can only test what it names.
- Easy to extend the grammar: a new expression is a new class.
- Inspectable: the tree can be printed, tested and explained.
What you pay
- A parser to maintain: and good error messages are work.
- Grows badly: big grammars need real parser tools.
- Performance: walking a tree per ride is slower than compiled code (fine for promotions).
- A language is a product: people will want variables, dates, functions…
| If… | Use instead |
|---|---|
| The rules are few and change rarely | Plain code |
| The rules need a full language | An embedded scripting language or rules engine, sandboxed |
| Rules are simple key/value thresholds | A configuration table |
| You need to run many operations over the tree | Interpreter plus 21 Visitor (print, evaluate, optimise) |
8. How it goes wrong
9. Remember it and say it
How this hook works: what “A recipe card” means
What the slogan means, phrase by phrase
- “A recipe card”
- A rule written in text.
- “a few instruction kinds”
Flag,Compare,And,Or,Not.- “a tree you can follow”
- Each node evaluates its children.
Why this picture fits: Cooks read new recipes without being retrained, because recipes reuse the same few kinds of instruction.
The story → the concept
| The kitchen | The code |
|---|---|
| The recipe card | "member or (hour >= 22 and minutes < 30)" |
| Kinds of instruction | The expression classes |
| Reading the card | The parser building a tree |
| Following it | tree.evaluate(ride) |
Watch it on an example
- The parser reads the rule and builds Or(member, And(hour ≥ 22, minutes < 30)).
- For ride B, Or asks member: no; then asks And.
- And asks hour ≥ 22 (23: yes) and minutes < 30 (15: yes): the promotion applies.
The trap it saves you from
Writing and deploying a new function for every rule that's just a new combination of the same conditions.
It still works when…
Yes: search query syntax, spreadsheet formulas, feature-flag targeting rules and SQL WHERE clauses all use small interpreted languages.
In an interview or code review
- Define the language first: five expression kinds over four ride fields; write the grammar in three lines.
- Separate parsing from interpreting: the parser builds the tree once; the tree evaluates per ride.
- Say why not eval: a closed grammar can't do anything you didn't define.
- Know the limit: for big languages, a parser generator or an existing rules engine.
10. Check yourself
What does each class represent in Interpreter?
Which nodes are terminal here?
How does an And node evaluate?
Why not run marketing's text with eval()?
When should you parse a rule?
11. Go deeper
| Resource | Why |
|---|---|
| 21 Visitor | Several operations over the same expression tree. |
| 10 Composite | An expression tree is a composite. |
Python ast | Python's own syntax trees, built by a parser. |
| Wikipedia: Interpreter pattern | Structure and examples. |