Interpreter
Purpose
Give a small language a typed representation and an evaluator. The pattern is worth its cost when
users — or configuration, or another service — must express conditions the code cannot enumerate:
filter expressions, pricing formulas, routing rules, feature-flag conditions, alert predicates.
Two boundaries help scope it. Parsing is a separate responsibility even though a usable language
normally needs it. And the GoF class-per-production approach fits small grammars best; scoping,
user functions, recursion, optimization or strict isolation may justify a mature runtime, bytecode
VM, or existing language rather than an ever-growing switch.
The sealed AST plus pattern-switch examples require Java 21 or later without preview features.
Inspect the target compiler/runtime and engine versions; adopting Interpreter does not authorize
an upgrade. On older baselines use supported per-node dispatch or an existing evaluator.
When it is the answer
Users must express conditions you cannot enumerate at compile time,
in a language you control and can keep small
→ Interpreter, with a sealed AST.
Rules change more often than releases and must be stored as data
→ Interpreter, with the AST persisted or parsed from stored text.
Expressions must be inspected as well as evaluated — explained,
optimised, translated to SQL, shown in a UI
→ a typed AST is the point; evaluation is one operation over it
(and several operations suggest gof-visitor).
When it is not
- The conditions are known and few. A sealed set of named rules, or configuration with a
fixed shape, is simpler than a language and cannot express nonsense.
- A suitable language already exists. CEL, JSONLogic, a rules engine or a query DSL is
usually cheaper than designing, documenting, versioning and securing your own.
- The grammar is non-trivial. Precedence, associativity, error recovery and position tracking
are what parser generators and combinator libraries do properly.
- The expression comes from an untrusted source and the chosen engine exposes constructors,
reflection, bean/type access or host functions. That can become arbitrary code execution.
Prefer a purpose-built restricted language or isolation. SpEL
SimpleEvaluationContext limits
features but is not a sandbox guarantee: reachable getters/accessors/functions may have effects.
- It has an unverified latency target. A tree walk need not allocate per node and may be fast
enough. Profile parsing and evaluation before adding closure or bytecode compilation.
Modern Java expression
Classical Modern
─────────────────────────────────── ───────────────────────────────────
abstract class Expression sealed interface Expr
abstract Value interpret(Context) permits Literal, Var, And, Or, Cmp
one interpret() per node class one exhaustive switch — the whole
evaluator readable in one place, and
a new node breaks it at compile time
Context as a mutable map an immutable context record, or a
Function<String, Value> resolver
evaluation only several folds over the same AST:
evaluate, describe, toSql, validate
The switch form is preferable while you own every node type: the evaluator is one function
rather than scattered across the node classes, and adding a node type produces a compile error at
every fold. Keep interpret() on the nodes only when third parties contribute node types
(java-composition-over-inheritance).
Decision rules
IF expressions come from users or another service
THEN they are untrusted input. Bound text size, parse depth/node count,
function capabilities and evaluation work. In-process wall-clock timeout alone
cannot safely stop arbitrary non-cooperative code; use cooperative budgets or isolation.
IF a general-purpose engine (SpEL, OGNL, MVEL, EL, a template engine)
is being fed a string that a request can influence
THEN audit the evaluation context and reachable capabilities. Full reflection/type/
method access can become arbitrary code execution; a documented restricted mode
may be acceptable after adversarial tests.
IF grammar complexity exceeds what a small, bounded recursive-descent parser can maintain
THEN consider a generator or combinators. Precedence alone does not mandate a dependency;
every option needs full-input consumption, position errors and adversarial limit tests.
IF the AST is walked repeatedly
THEN consider caching only after measuring parse cost. Bound size/weight, key by grammar/schema
and relevant semantic configuration, and recheck caller permissions. Do not cache another
caller's authorization or captured context under expression text alone.
IF evaluation is in a hot path
THEN compare tree walking, specialized closures, bytecode and vectorized/batched
evaluation. Compilation has warm-up, code-cache and eviction costs; benchmark
representative expressions and polymorphism (jmh-microbenchmarks).
IF an expression must run in more than one process or version
THEN the grammar is a contract: version it, and decide what an older
evaluator does with a node it does not know.
IF nodes hold evaluation state
THEN the AST is not shareable. Keep nodes immutable and pass the
context as a parameter.
IF the language grows scoping, user functions, loops or recursion
THEN revisit parser/runtime, resource accounting, stack behavior, debugging and
compatibility. This is a complexity trigger, not an automatic prohibition.
Cross-cutting checks
- Concurrency. An immutable AST is safe to share across threads and to cache; that is the
design to hold. The failure is a node that caches its last result or holds a reference to the
context — then the same expression evaluated concurrently for two users can return one user's
answer to the other. Evaluation state belongs in a per-call context
(
java-immutability).
- Distribution. An expression transmitted between services makes the grammar a wire contract.
A node type added by a newer producer must have a defined meaning for an older evaluator —
usually "reject the expression", never "ignore the node", which silently changes a filter's
meaning and can widen an authorisation rule (
rpc-and-api-contracts).
- Performance. Tree walking incurs dispatch/branching but need not allocate per node after the
AST exists. Cache parsing only with bounded cardinality, specialize only hot stable expressions,
and reorder predicates only when error, null and short-circuit semantics permit it. Measure
parse, evaluation, allocation and generated-code retention separately
(
jfr-and-async-profiler, allocation-profiling).
- Testing. Property-based testing suits this unusually well: generate expressions, assert
semantic laws valid for the language's null/error model, and compare the compiled
evaluator against the tree walker on random inputs. Add fuzzed text against the parser, and
assert that pathological input is rejected by the limits rather than by a
StackOverflowError.
Review checklist
Deliver the language's type/null/error/short-circuit contract, capability and resource limits,
validated execution boundary, and relevant checks. Label missing parser, SQL dialect or engine
validation explicitly; AST immutability and a sealed hierarchy alone do not establish safety.
References
- Grammar, alternatives and safety — when to embed CEL,
JSONLogic or a rules engine instead; the expression-language RCE class with the shapes to look
for; parsing options and their maintenance trade-offs; resource limits for untrusted
expressions; and closure compilation with its measurement requirements. Read before designing
a language.
- Worked example — a filter language for a search API: the sealed
AST, the evaluator as a fold, a second fold that compiles to SQL, closure compilation for the
hot path, and the limits applied at the boundary. Read when implementing.
1---2name: gof-interpreter3description: Interpreter in modern Java: representing a small language as a typed tree and evaluating it, expressed today as a sealed AST with an exhaustive switch rather than an eval() method per node. Covers parsing as a separate problem the pattern does not solve, when an existing expression language beats writing one, how general expression engines become code-execution surfaces when exposed with unsafe capabilities, the resource bounds an interpreter over untrusted input needs, and closure compilation when tree walking is too slow. Use when a filter, rule or formula language is designed, when configuration has grown conditionals, when someone proposes embedding an expression evaluator, or when an expression from a request is passed to a template or EL engine. Does not cover adding operations over an existing tree (gof-visitor), the tree structure itself (gof-composite), query specifications over a database (query-objects-and-specifications), or JIT compilation of Java (jit-compilation).4---56# Interpreter78## Purpose910Give a small language a typed representation and an evaluator. The pattern is worth its cost when11users — or configuration, or another service — must express conditions the code cannot enumerate:12filter expressions, pricing formulas, routing rules, feature-flag conditions, alert predicates.1314Two boundaries help scope it. Parsing is a separate responsibility even though a usable language15normally needs it. And the GoF class-per-production approach fits small grammars best; scoping,16user functions, recursion, optimization or strict isolation may justify a mature runtime, bytecode17VM, or existing language rather than an ever-growing `switch`.1819The sealed AST plus pattern-switch examples require Java 21 or later without preview features.20Inspect the target compiler/runtime and engine versions; adopting Interpreter does not authorize21an upgrade. On older baselines use supported per-node dispatch or an existing evaluator.2223## When it is the answer2425```text26Users must express conditions you cannot enumerate at compile time,27in a language you control and can keep small28 → Interpreter, with a sealed AST.2930Rules change more often than releases and must be stored as data31 → Interpreter, with the AST persisted or parsed from stored text.3233Expressions must be inspected as well as evaluated — explained,34optimised, translated to SQL, shown in a UI35 → a typed AST is the point; evaluation is one operation over it36 (and several operations suggest gof-visitor).37```3839## When it is not4041- **The conditions are known and few.** A sealed set of named rules, or configuration with a42 fixed shape, is simpler than a language and cannot express nonsense.43- **A suitable language already exists.** CEL, JSONLogic, a rules engine or a query DSL is44 usually cheaper than designing, documenting, versioning and securing your own.45- **The grammar is non-trivial.** Precedence, associativity, error recovery and position tracking46 are what parser generators and combinator libraries do properly.47- **The expression comes from an untrusted source and the chosen engine exposes constructors,48 reflection, bean/type access or host functions.** That can become arbitrary code execution.49 Prefer a purpose-built restricted language or isolation. SpEL `SimpleEvaluationContext` limits50 features but is not a sandbox guarantee: reachable getters/accessors/functions may have effects.51- **It has an unverified latency target.** A tree walk need not allocate per node and may be fast52 enough. Profile parsing and evaluation before adding closure or bytecode compilation.5354## Modern Java expression5556```text57Classical Modern58─────────────────────────────────── ───────────────────────────────────59abstract class Expression sealed interface Expr60 abstract Value interpret(Context) permits Literal, Var, And, Or, Cmp6162one interpret() per node class one exhaustive switch — the whole63 evaluator readable in one place, and64 a new node breaks it at compile time6566Context as a mutable map an immutable context record, or a67 Function<String, Value> resolver6869evaluation only several folds over the same AST:70 evaluate, describe, toSql, validate71```7273The `switch` form is preferable while you own every node type: the evaluator is one function74rather than scattered across the node classes, and adding a node type produces a compile error at75every fold. Keep `interpret()` on the nodes only when third parties contribute node types76(`java-composition-over-inheritance`).7778## Decision rules7980```text81IF expressions come from users or another service82THEN they are untrusted input. Bound text size, parse depth/node count,83 function capabilities and evaluation work. In-process wall-clock timeout alone84 cannot safely stop arbitrary non-cooperative code; use cooperative budgets or isolation.8586IF a general-purpose engine (SpEL, OGNL, MVEL, EL, a template engine)87is being fed a string that a request can influence88THEN audit the evaluation context and reachable capabilities. Full reflection/type/89 method access can become arbitrary code execution; a documented restricted mode90 may be acceptable after adversarial tests.9192IF grammar complexity exceeds what a small, bounded recursive-descent parser can maintain93THEN consider a generator or combinators. Precedence alone does not mandate a dependency;94 every option needs full-input consumption, position errors and adversarial limit tests.9596IF the AST is walked repeatedly97THEN consider caching only after measuring parse cost. Bound size/weight, key by grammar/schema98 and relevant semantic configuration, and recheck caller permissions. Do not cache another99 caller's authorization or captured context under expression text alone.100101IF evaluation is in a hot path102THEN compare tree walking, specialized closures, bytecode and vectorized/batched103 evaluation. Compilation has warm-up, code-cache and eviction costs; benchmark104 representative expressions and polymorphism (jmh-microbenchmarks).105106IF an expression must run in more than one process or version107THEN the grammar is a contract: version it, and decide what an older108 evaluator does with a node it does not know.109110IF nodes hold evaluation state111THEN the AST is not shareable. Keep nodes immutable and pass the112 context as a parameter.113114IF the language grows scoping, user functions, loops or recursion115THEN revisit parser/runtime, resource accounting, stack behavior, debugging and116 compatibility. This is a complexity trigger, not an automatic prohibition.117```118119## Cross-cutting checks120121- **Concurrency.** An immutable AST is safe to share across threads and to cache; that is the122 design to hold. The failure is a node that caches its last result or holds a reference to the123 context — then the same expression evaluated concurrently for two users can return one user's124 answer to the other. Evaluation state belongs in a per-call context125 (`java-immutability`).126- **Distribution.** An expression transmitted between services makes the grammar a wire contract.127 A node type added by a newer producer must have a defined meaning for an older evaluator —128 usually "reject the expression", never "ignore the node", which silently changes a filter's129 meaning and can widen an authorisation rule (`rpc-and-api-contracts`).130- **Performance.** Tree walking incurs dispatch/branching but need not allocate per node after the131 AST exists. Cache parsing only with bounded cardinality, specialize only hot stable expressions,132 and reorder predicates only when error, null and short-circuit semantics permit it. Measure133 parse, evaluation, allocation and generated-code retention separately134 (`jfr-and-async-profiler`, `allocation-profiling`).135- **Testing.** Property-based testing suits this unusually well: generate expressions, assert136 semantic laws valid for the language's null/error model, and compare the compiled137 evaluator against the tree walker on random inputs. Add fuzzed text against the parser, and138 assert that pathological input is rejected by the limits rather than by a `StackOverflowError`.139140## Review checklist141142- [ ] The language is small, and its growth is deliberately bounded143- [ ] An existing expression language was considered and rejected for a stated reason144- [ ] Any user-influenced engine input runs with an audited allowlist/capability model or isolation145- [ ] Text/token sizes, AST depth/nodes, expensive primitive work and result sizes have enforced bounds146- [ ] Evaluation has no side effects and no access to the host environment147- [ ] Parsing is separated; any AST cache is measured, bounded and resistant to key-cardinality abuse148- [ ] AST nodes are immutable; evaluation state lives in a per-call context149- [ ] An unknown node type from a newer producer is rejected, not ignored150- [ ] Performance claims about compilation are backed by a benchmark151152Deliver the language's type/null/error/short-circuit contract, capability and resource limits,153validated execution boundary, and relevant checks. Label missing parser, SQL dialect or engine154validation explicitly; AST immutability and a sealed hierarchy alone do not establish safety.155156## References157158- [Grammar, alternatives and safety](references/grammar-and-alternatives.md) — when to embed CEL,159 JSONLogic or a rules engine instead; the expression-language RCE class with the shapes to look160 for; parsing options and their maintenance trade-offs; resource limits for untrusted161 expressions; and closure compilation with its measurement requirements. Read before designing162 a language.163- [Worked example](references/worked-example.md) — a filter language for a search API: the sealed164 AST, the evaluator as a fold, a second fold that compiles to SQL, closure compilation for the165 hot path, and the limits applied at the boundary. Read when implementing.