Behavioral Pattern (GoF)

Interpreter

Defines a representation for a language's grammar and an interpreter that uses that representation to process sentences — each grammar rule becomes a class, and the syntax tree is built by composing those classes.

Intent

Given a language, define a representation for its grammar along with an interpreter that uses that representation to process sentences in the language. Each grammar rule maps to a class; complex expressions are formed by composing objects of those classes into syntax trees — which are, therefore, Composites.

Cataloged by the GoF (1994) as a behavioral pattern, Interpreter is more educational than widely applied in modern projects. It's useful for simple languages and small DSLs where the grammar has few rules and the manual expressiveness of classes pays off. For real grammars — with complex tokens, operator precedence, lexical and syntax error handling — use dedicated tools: parser generators (ANTLR, PEG.js, Tree-sitter), parser combinators (Parsimmon, Chevrotain), or PEG/BNF grammars. Interpreter's class-based approach quickly becomes unviable as the grammar grows.

Problem

Imagine a configuration system that accepts filters written as mathematical expressions with variables: a + b * 3, where a and b come from a context (record, session, configuration). The naive approach evaluates the expression as a string at runtime — unsafe and inflexible:

// Naive approach — DON'T do this:
function evaluate(expression: string, context: Record<string, number>): number {
  // eval() executes arbitrary code — code injection risk.
  // No validation, no typing, no useful error messages.
  return eval(expression.replace(/([a-z]+)/g, (_, v) => String(context[v] ?? 0)));
}

console.log(evaluate('a + b * 3', { a: 5, b: 4 }));   // 17 (but dangerous!)
// Worse: evaluate('process.exit(1)', {}) crashes the process.

Interpreter solves this by mapping each grammar rule to a class. The expression is represented as a tree of objects — built manually or by a simple parser. Each node knows how to interpret itself given a context, and evaluation happens through polymorphic calls over the tree — no eval(), no arbitrary code.

Solution

Interpreter organizes the code into four participants:

  1. AbstractExpression (interface): declares the interpret(context): T method that every expression must implement. The return type depends on the language: number, boolean, string. The context carries the external state needed for evaluation (variable values, data records, settings).
  2. TerminalExpression: implements the interpretation of a terminal symbol of the grammar — a symbol that doesn't expand into others. Examples: NumberLiteral (returns a literal value) and Variable (looks up the context). These are the syntax tree's leaves.
  3. NonTerminalExpression: implements a grammar rule that references other expressions. Examples: Sum, Subtraction, Multiplication. Each NonTerminalExpression holds references to sub-expressions (left and right) and delegates interpretation to them before combining the results. Since each NonTerminalExpression contains other IExpression objects, the structure is a Composite.
  4. Context: carries the global information needed for interpretation — typically the variable values. In TypeScript, it can be a simple Record<string, number>; in more complex systems, an object with query methods and accumulated state.

Evaluation happens by calling rootExpression.interpret(context): the method recursively walks the tree, from internal nodes down to the leaves, and returns the combined result. The grammar is implicit in the classes' structure — there's no parser generator, just composed objects.

Structure

         «interface»
         IExpression
  ┌────────────────────────────────────────┐
  │ + interpret(ctx: Context): number      │
  └────────────────────────────────────────┘
               ▲
   ┌───────────┼────────────────────────────────┐
   │           │           │           │        │
NumberLiteral Variable    Sum    Subtraction  Multiplication
(Terminal) (Terminal) (NonTerminal)(NonTerminal)(NonTerminal)
   │           │
   │ returns   │ looks up ctx[name]
   │ this.value│
   │           │
   └─ doesn't reference other IExpression (leaf)

Sum / Subtraction / Multiplication:
  ┌───────────────────────────────────────────────┐
  │ - left: IExpression                           │
  │ - right: IExpression                          │
  │ + interpret(ctx): number                      │
  │   return left.interpret(ctx)                  │
  │        OP right.interpret(ctx)                │
  └───────────────────────────────────────────────┘
  (internal Composite — each node can contain other nodes)


Context:
  { a: 5, b: 4 }   (Record<string, number>)


Flow for "a + (b * 3)", ctx = {a:5, b:4}:

  Sum
  ├── Variable('a')  → interpret(ctx) = 5
  └── Multiplication
       ├── Variable('b')      → interpret(ctx) = 4
       └── NumberLiteral(3)   → interpret(ctx) = 3
       → 4 * 3 = 12
  → 5 + 12 = 17

Code examples

Example 1 — Mathematical expressions with variables and context

A complete implementation with the IExpression interface, two terminal expressions (NumberLiteral and Variable), and three non-terminals (Sum, Subtraction, Multiplication). The context is a simple map of variables. The tree is built manually — in real systems, a parser would do this work.

// ── Context ───────────────────────────────────────────────────
// Map of variables available during interpretation.
type Context = Record<string, number>;

// ── AbstractExpression ────────────────────────────────────────
interface IExpression {
  interpret(ctx: Context): number;
}

// ── TerminalExpressions ───────────────────────────────────────

// NumberLiteral: a leaf that returns a literal value.
class NumberLiteral implements IExpression {
  constructor(private readonly value: number) {}

  interpret(_ctx: Context): number {
    return this.value;
  }
}

// Variable: a leaf that looks up the context by name.
class Variable implements IExpression {
  constructor(private readonly name: string) {}

  interpret(ctx: Context): number {
    if (!(this.name in ctx)) {
      throw new Error(`Variable '${this.name}' not defined in context`);
    }
    return ctx[this.name];
  }
}

// ── NonTerminalExpressions ────────────────────────────────────

class Sum implements IExpression {
  constructor(
    private readonly left: IExpression,
    private readonly right: IExpression
  ) {}

  interpret(ctx: Context): number {
    return this.left.interpret(ctx) + this.right.interpret(ctx);
  }
}

class Subtraction implements IExpression {
  constructor(
    private readonly left: IExpression,
    private readonly right: IExpression
  ) {}

  interpret(ctx: Context): number {
    return this.left.interpret(ctx) - this.right.interpret(ctx);
  }
}

class Multiplication implements IExpression {
  constructor(
    private readonly left: IExpression,
    private readonly right: IExpression
  ) {}

  interpret(ctx: Context): number {
    return this.left.interpret(ctx) * this.right.interpret(ctx);
  }
}

// ── Usage ────────────────────────────────────────────────────
// Expression: a + (b * 3) - 1
// AST built manually — in production, a parser would do this.
const expression: IExpression = new Subtraction(
  new Sum(
    new Variable('a'),
    new Multiplication(new Variable('b'), new NumberLiteral(3))
  ),
  new NumberLiteral(1)
);

// Context 1
const ctx1: Context = { a: 5, b: 4 };
console.log(expression.interpret(ctx1));
// 5 + (4 * 3) - 1 = 16

// Context 2 — same expression, different values
const ctx2: Context = { a: 10, b: 2 };
console.log(expression.interpret(ctx2));
// 10 + (2 * 3) - 1 = 15

// Composite expression: (a - b) * (a + b)  → difference of squares
const diffOfSquares: IExpression = new Multiplication(
  new Subtraction(new Variable('a'), new Variable('b')),
  new Sum(new Variable('a'), new Variable('b'))
);
// a=5, b=3 → (5-3)*(5+3) = 2*8 = 16
console.log(diffOfSquares.interpret({ a: 5, b: 3 }));   // 16

Example 2 — Mini boolean filter DSL

Interpreter is also well suited for small query or filter DSLs. In this example, the language has three rules: Equals (terminal — checks whether a record's field has a given value), And, and Or as non-terminals. The context is the record being evaluated. The expression filters a list of records without eval() and with type-safe composition.

// ── Context: a data record ────────────────────────────────────
type Record_ = Record<string, string | number>;

// ── AbstractExpression ────────────────────────────────────────
interface ICondition {
  evaluate(record: Record_): boolean;
}

// ── TerminalExpression: field = value ─────────────────────────
class Equals implements ICondition {
  constructor(
    private readonly field: string,
    private readonly value: string | number
  ) {}

  evaluate(record: Record_): boolean {
    return record[this.field] === this.value;
  }
}

// ── NonTerminalExpression: And ────────────────────────────────
class And implements ICondition {
  constructor(
    private readonly left: ICondition,
    private readonly right: ICondition
  ) {}

  evaluate(record: Record_): boolean {
    // Short-circuit: if left is false, right isn't evaluated.
    return this.left.evaluate(record) && this.right.evaluate(record);
  }
}

// ── NonTerminalExpression: Or ──────────────────────────────────
class Or implements ICondition {
  constructor(
    private readonly left: ICondition,
    private readonly right: ICondition
  ) {}

  evaluate(record: Record_): boolean {
    return this.left.evaluate(record) || this.right.evaluate(record);
  }
}

// ── Usage ────────────────────────────────────────────────────
// DSL: status = 'active' AND (plan = 'pro' OR plan = 'enterprise')
const filter: ICondition = new And(
  new Equals('status', 'active'),
  new Or(
    new Equals('plan', 'pro'),
    new Equals('plan', 'enterprise')
  )
);

const records: Record_[] = [
  { status: 'active',   plan: 'pro' },
  { status: 'active',   plan: 'free' },
  { status: 'inactive', plan: 'pro' },
  { status: 'active',   plan: 'enterprise' },
];

const result = records.filter(r => filter.evaluate(r));
console.log(result);
// [{ status: 'active', plan: 'pro' }, { status: 'active', plan: 'enterprise' }]

// The filter tree can be built dynamically from a JSON
// configuration — no eval(), with full control over what
// the language can express.

When to use

  • For small, stable DSLs: query filters, business rule expressions, small configuration mini-languages, or templates with few constructs. The number of expression classes should be small — up to ten rules is reasonable; beyond that, prefer a parser generator.
  • When the grammar is well known and rarely changes: Interpreter is easy to extend with new operators (new classes), but any change to the grammar's core structure can cascade through every existing class.
  • For educational purposes and prototyping: the pattern makes a grammar's structure explicit in class code — useful for learning how parsers work internally before using automated tools.
  • When security is critical: an Interpreter-based DSL executes only the operations you defined — with no risk of code injection via eval(). Business rule systems, filtering configurations, and validation engines benefit from this explicit control.

When to avoid

  • Complex grammars: each rule becomes a class; a grammar with 20 rules generates 20 classes — and the interaction between them quickly becomes hard to maintain. Use ANTLR, PEG.js, Chevrotain, or Tree-sitter for any language with non-trivial operator precedence, error recovery, or complex tokens.
  • When performance is critical: recursing over the whole tree on every evaluation can be slow for complex or high-frequency expressions. Real compilers don't interpret the AST directly — they compile to bytecode or native code.
  • When the pattern already exists in the language: many languages have idiomatic ways to compose predicates — closures, functional combinators, regular expressions. Class-based Interpreter is often more verbose than a functional solution.

Pros and cons

Pros

  • The grammar is explicit in the code — each rule is a class with a descriptive name, easy to locate.
  • Open for new rules: adding an operator means creating a new class implementing the interface — without modifying existing expressions.
  • Safe, controlled evaluation: no eval(), no code injection, no access to unauthorized APIs.
  • The expression tree can be built dynamically (from JSON, a database, or a UI) — enabling runtime-configurable DSLs.
  • Clear separation between the expression's structure and the evaluation mechanism.

Cons

  • Class explosion for non-trivial grammars — one class per rule becomes unviable above ~10-15 rules.
  • No native support for lexical or syntax errors: building the tree manually doesn't detect invalid sentences — requires a separate parser.
  • Lower performance than bytecode or compiled code — interpreted recursion over the AST is orders of magnitude slower than native code.
  • Difficulty implementing operator precedence, associativity, and grouping without a dedicated parser.

Common pitfalls

1. Confusing Interpreter with Composite

Interpreter uses Composite internally — the syntax tree is a Composite where the NonTerminalExpressions are the composite nodes and the TerminalExpressions are the leaves. The difference is one of intent: Composite organizes part-whole structures and treats leaves and composites uniformly; Interpreter interprets sentences of a specific grammar. When someone adds an interpret() method to an existing Composite, they're essentially applying Interpreter over the Composite's structure.

2. Uncontrolled class growth

The most critical scaling pitfall: every new grammar rule requires a new class. A filter DSL that starts with Equals, And, and Or seems reasonable. But once you add GreaterThan, LessThan, Contains, StartsWith, Not, Between, In — the number of classes grows quickly. Once that point is reached, migrate to a parser combinator or a parser generator.

Rule of thumb: if the grammar has more than 8 to 10 expression classes, seriously evaluate using a dedicated parsing library. Interpreter as a class-based pattern is meant for genuinely small DSLs — not for real languages.

3. No error handling when building the AST

Building the tree manually doesn't validate the sentence. An expression like new Sum(null, new NumberLiteral(3)) compiles without errors but blows up at runtime with a null reference exception. In TypeScript, use non-nullable types and strict mode to mitigate this; in PHP, declare types in the interface. For full validation of externally supplied sentences (strings typed by the user), a parser with useful error messages is indispensable.

4. Interpreter vs Composite — when to use which

Use Composite when the focus is the hierarchical part-whole structure and the main operation is uniform across all nodes (e.g.: calculating a folder's total size). Use Interpreter when the focus is evaluating sentences of a language with explicit grammar rules — where each node type has different evaluation semantics (Sum adds, Multiplication multiplies, Variable looks up the context). In practice, Interpreter is always a Composite; but not every Composite is an Interpreter.

Related patterns

Interpreter relates directly to structural and behavioral patterns that organize and traverse hierarchies:

Composite is Interpreter's structural foundation: the abstract syntax tree (AST) is a Composite where the NonTerminalExpressions are composite nodes and the TerminalExpressions are leaves. The interpret() method walks that tree recursively — exactly like the operation() method walks a Composite. Visitor is Interpreter's natural complement: while Interpreter defines a single operation (interpret()) directly on each expression class, Visitor separates the operations from the classes — allowing new behaviors (printing, optimizing, compiling) to be added to the same AST without modifying it. In real compilers, the AST is built with Composite, interpreted with Interpreter in early phases, and later traversed by multiple analysis and transformation passes implemented as Visitors.