---
title: 'Interpreter and Null Object'
source: 'https://academia.sh/en/courses/design-patterns/interpreter-and-null-object'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:13+00:00'
license: 'CC BY-SA 4.0'
---

# Interpreter and Null Object

Comparing tariff rules written as code against a small rule language interpreted from text: the number of code files and data lines edited when a new rule is added, the rule tree's node count, catching invalid text; also measuring the representation of a missing discount as a null value versus a null object by the number of null checks and the number of call sites that throw an error.

Mediator stored an order, memento stored a copy; both held data, but the data was still written
into the code. On the tariff-rule side, this has reached its limit. Every new conditional rule
means a source change, yet the person writing the rules is not a developer but the tariff
department. The rule needs to be written as text and evaluated at run time.

**Interpreter** turns every building block of a small language into an object and translates text
into a tree made of these objects; the requested evaluation runs from the tree's root, walking
down the tree. The same section raises a second question: what should the result be when no
discount applies to a shipment. **Null object** represents this absence with an object that does
nothing but carries the same contract. The numbers to measure are the number of code files edited
when a new rule is added, the rule tree's node count, the number of null checks, and the number of
call sites that throw an error.

## The Rule Language

The language is made of three things: comparing a field to a number, `and`, `or`. There is no
precedence between connectives; they are evaluated left to right. Rules sit in a text file —
name, rate, condition.

```sh
mkdir -p embedded interpreter null-object

cat > rules.txt <<'EOF'
volume-discount 8 weight > 20
remote-zone 15 zone = 3 and weight < 5
valuable-shipment 4 value > 5000 or volume > 100
EOF
```

```js
// data.mjs — shipments the rules will be tried against
export const SHIPMENTS = [
  { code: "GN-1", weight: 2, volume: 8, zone: 1, value: 400 },
  { code: "GN-2", weight: 25, volume: 60, zone: 2, value: 900 },
  { code: "GN-3", weight: 3, volume: 20, zone: 3, value: 200 },
  { code: "GN-4", weight: 6, volume: 140, zone: 2, value: 8000 },
];
```

In the comparison version, the same three rules are written directly as code.

```js
// embedded/rules.mjs — conditions written as code, changing them requires a release
export const RULES = [
  ["volume-discount", 8, (s) => s.weight > 20],
  ["remote-zone", 15, (s) => s.zone === 3 && s.weight < 5],
  ["valuable-shipment", 4, (s) => s.value > 5000 || s.volume > 100],
];
```

In the interpreter version, every building block is an object. A leaf node is a comparison, an
interior node is a connective; each node also carries the node count beneath it.

```js
// interpreter/language.mjs — the rule language's nodes and parser
const FIELDS = ["weight", "volume", "zone", "value"];
const RELATIONS = [">", "<", "="];

export const compare = (field, relation, number) => ({
  type: "compare",
  nodeCount: 1,
  evaluate: (s) => (relation === ">" ? s[field] > number : relation === "<" ? s[field] < number : s[field] === number),
});

const binary = (type, combine) => (left, right) => ({
  type,
  nodeCount: left.nodeCount + right.nodeCount + 1,
  evaluate: (s) => combine(left.evaluate(s), right.evaluate(s)),
});

export const and = binary("and", (a, b) => a && b);
export const or = binary("or", (a, b) => a || b);

function term([field, relation, number]) {
  if (FIELDS.includes(field) === false) throw new SyntaxError(`unknown field: ${field}`);
  if (RELATIONS.includes(relation) === false) throw new SyntaxError(`unknown relation: ${relation}`);
  return compare(field, relation, Number(number));
}

export function parse(text) {
  const tokens = text.trim().split(/\s+/);
  let tree = term(tokens.splice(0, 3));
  while (tokens.length > 0) {
    const connective = tokens.shift();
    if (connective !== "and" && connective !== "or") throw new SyntaxError(`unknown connective: ${connective}`);
    tree = (connective === "and" ? and : or)(tree, term(tokens.splice(0, 3)));
  }
  return tree;
}

export const load = (content) =>
  content
    .split("\n")
    .filter((s) => s.trim().length > 0)
    .map((s) => {
      const [name, rate, ...condition] = s.trim().split(/\s+/);
      return [name, Number(rate), parse(condition.join(" "))];
    });
```

## Equality of the Two Versions

```js
// run.mjs — compares rules written as code against rules interpreted from text
import { readFileSync } from "node:fs";
import { SHIPMENTS } from "./data.mjs";
import { RULES } from "./embedded/rules.mjs";
import { load } from "./interpreter/language.mjs";

const textRules = load(readFileSync("rules.txt", "utf8"));

const find = (rules, s, run) => {
  for (const [name, rate, condition] of rules) if (run(condition, s)) return `${name} ${rate}%`;
  return "no discount";
};

let deviation = 0;
for (const s of SHIPMENTS) {
  const a = find(RULES, s, (k, x) => k(x));
  const b = find(textRules, s, (k, x) => k.evaluate(x));
  if (a !== b) deviation += 1;
  console.log(`${s.code} embedded=${a.padEnd(21)} interpreter=${b}`);
}
console.log(`deviation between the two versions = ${deviation}`);
console.log(`rule tree node counts = ${textRules.map(([, , k]) => k.nodeCount).join(" ")}`);
```

```
GN-1 embedded=no discount           interpreter=no discount
GN-2 embedded=volume-discount 8%    interpreter=volume-discount 8%
GN-3 embedded=remote-zone 15%       interpreter=remote-zone 15%
GN-4 embedded=valuable-shipment 4%  interpreter=valuable-shipment 4%
deviation between the two versions = 0
rule tree node counts = 1 3 3
```

The deviation is zero. The last line gives the first cost item: in the code version, a rule is a
single function call; in the interpreter version, they are trees of one, three, and three nodes.
Evaluating a rule with one connective means three object visits, two `evaluate` calls, and one
combination.

## When a Fourth Rule Is Added

The tariff department wants a new rule: twelve percent for shipments lighter than five kilograms
in the first zone, ahead of the other rules. It is added to both versions, and the edited files
are counted.

```js
// new-rule.mjs — a fourth rule is added to both versions, edited files are counted
import { appendFileSync, copyFileSync, cpSync, readFileSync, writeFileSync } from "node:fs";
import { SHIPMENTS } from "./data.mjs";
import { load } from "./interpreter/language.mjs";

cpSync("embedded", "embedded-new", { recursive: true });
copyFileSync("rules.txt", "rules-new.txt");

writeFileSync(
  "embedded-new/rules.mjs",
  readFileSync("embedded/rules.mjs", "utf8").replace(
    '  ["volume-discount", 8, (s) => s.weight > 20],',
    '  ["express-campaign", 12, (s) => s.weight < 5 && s.zone === 1],\n  ["volume-discount", 8, (s) => s.weight > 20],',
  ),
);
appendFileSync("rules-new.txt", "express-campaign 12 weight < 5 and zone = 1\n");

const codeDiffers = (a, b) => (readFileSync(a, "utf8") === readFileSync(b, "utf8") ? 0 : 1);
console.log(`edited .mjs file: embedded=${codeDiffers("embedded/rules.mjs", "embedded-new/rules.mjs")}  interpreter=0`);
console.log(`edited data line : embedded=0  interpreter=1`);

const newText = load(readFileSync("rules-new.txt", "utf8"));
const { RULES } = await import("./embedded-new/rules.mjs");
const find = (rules, s, run) => {
  for (const [name, rate, condition] of rules) if (run(condition, s)) return `${name} ${rate}%`;
  return "no discount";
};
let deviation = 0;
for (const s of SHIPMENTS) {
  if (find(RULES, s, (k, x) => k(x)) !== find(newText, s, (k, x) => k.evaluate(x))) deviation += 1;
}
console.log(`GN-1 with the new rule: ${find(newText, SHIPMENTS[0], (k, x) => k.evaluate(x))}`);
console.log(`deviation with four rules = ${deviation}`);

for (const bad of ["weight >> 20", "volume > 5 but value > 10", "temperature > 4"]) {
  try {
    load(`x 5 ${bad}`);
    console.log(`"${bad}" accepted`);
  } catch (e) {
    console.log(`"${bad}" -> ${e.constructor.name}: ${e.message}`);
  }
}
```

```
edited .mjs file: embedded=1  interpreter=0
edited data line : embedded=0  interpreter=1
GN-1 with the new rule: express-campaign 12%
deviation with four rules = 0
"weight >> 20" -> SyntaxError: unknown relation: >>
"volume > 5 but value > 10" -> SyntaxError: unknown connective: but
"temperature > 4" -> SyntaxError: unknown field: temperature
```

One code file against zero. What gets added in the interpreter version is a line of text; because
the source does not change, no new release is required, and the person writing the rule does not
have to be a developer. The gain is in these two sentences, not in the line count.

The last three lines are the second cost item. Writing an invalid condition in the code version is
a syntax error, and it surfaces when the file loads; in the text version, an invalid condition is
only understood once the parser reads it. Here the parser catches all three error classes —
unknown relation, unknown connective, unknown field — but this catching did not come for free: the
field and relation lists were written by hand. The interpreter pattern's cost is the requirement
that everything the language accepts be explicitly enumerated in the parser. As the language
grows, the parser grows with it; a rule language with ten fields and five connectives is no longer
a small language, and at that point a real language-processing approach takes the pattern's place.

## Representing Absence

The second question is what to return when no discount is found. The first design returns a null
value, and every call site must check it — at the third call site, the check is skipped.

```js
// embedded/apply.mjs — returns null when there is no discount; three call sites must check for it
export const findDiscount = (rules, s) => {
  for (const [name, rate, condition] of rules) if (condition(s)) return { name, rate };
  return null;
};

export const fee = (d, base) => (d === null ? base : base - Math.round((base * d.rate) / 100));
export const label = (d) => (d === null ? "no discount" : `${d.name} ${d.rate}%`);
export const report = (d) => d.rate;
```

In the second design, the absence is also an object: it carries the same contract, and its rate
is zero.

```js
// null-object/apply.mjs — returns a discount object that does nothing when there is no discount
export const NULL_DISCOUNT = { name: "no discount", rate: 0 };

export const findDiscount = (rules, s) => {
  for (const [name, rate, condition] of rules) if (condition(s)) return { name, rate };
  return NULL_DISCOUNT;
};

export const fee = (d, base) => base - Math.round((base * d.rate) / 100);
export const label = (d) => (d.rate === 0 ? d.name : `${d.name} ${d.rate}%`);
export const report = (d) => d.rate;
```

```js
// absence.mjs — compares the null value and the null object across the two designs
import { readFileSync } from "node:fs";
import { SHIPMENTS } from "./data.mjs";
import { RULES } from "./embedded/rules.mjs";
import * as nullValue from "./embedded/apply.mjs";
import * as nullObject from "./null-object/apply.mjs";

const BASE = 10000;
let thrown = { nullValue: 0, nullObject: 0 };
let deviation = 0;

for (const s of SHIPMENTS) {
  const row = [];
  for (const [name, m] of [["nullValue", nullValue], ["nullObject", nullObject]]) {
    const d = m.findDiscount(RULES, s);
    let r;
    try {
      r = `${m.fee(d, BASE)}|${m.label(d)}|${m.report(d)}`;
    } catch (e) {
      thrown[name] += 1;
      r = `${m.fee(d, BASE)}|${m.label(d)}|${e.constructor.name}`;
    }
    row.push(r);
  }
  if (row[0] !== row[1]) deviation += 1;
  console.log(`${s.code} nullValue=${row[0].padEnd(34)} nullObject=${row[1]}`);
}

const checks = (path) => (readFileSync(path, "utf8").match(/=== null|!== null/g) ?? []).length;
console.log(`null checks: nullValue=${checks("embedded/apply.mjs")}  nullObject=${checks("null-object/apply.mjs")}`);
console.log(`call sites throwing an error: nullValue=${thrown.nullValue}  nullObject=${thrown.nullObject}`);
console.log(`deviation between the two designs = ${deviation}`);
```

```
GN-1 nullValue=10000|no discount|TypeError        nullObject=10000|no discount|0
GN-2 nullValue=9200|volume-discount 8%|8          nullObject=9200|volume-discount 8%|8
GN-3 nullValue=8500|remote-zone 15%|15            nullObject=8500|remote-zone 15%|15
GN-4 nullValue=9600|valuable-shipment 4%|4        nullObject=9600|valuable-shipment 4%|4
null checks: nullValue=2  nullObject=0
call sites throwing an error: nullValue=1  nullObject=0
deviation between the two designs = 1
```

For the three shipments that get a discount, the two designs produce the identical triple; the
deviation is only in the shipment with no discount, where the null-value design throws an error.
The check count is two against zero: in the null-object design there is no check to write, so
there is no check to skip either.

The null object's cost is the return of the ambiguity flagged in the chain of responsibility
lesson: returning `NULL_DISCOUNT` erases the difference between "no rule was found" and "a rule
with a rate of zero was found." The label text carries this difference, but the number does not.
The pattern is therefore applied only where the absence is **behaviorless**; where the absence
requires a decision, an explicit result type is superior to the null object.

## Summary

- Interpreter turns every building block of a small language into an object and translates text
  into a node tree; it produced the same result as the rules written as code, and the deviation
  came out at 0.
- When the fourth rule was added, the code version edited 1 source file, the interpreter version
  edited 0 source files and 1 data line; the two versions produced the same result at all four
  rules.
- Cost: a tree of 3 nodes is visited in place of a single function call, and every field,
  relation, and connective the language accepts must be enumerated in the parser by hand.
- Null object represents absence with an object that carries the same contract; the number of
  null checks dropped from 2 to 0, and the number of call sites throwing an error because of a
  skipped check dropped from 1 to 0.
- The null object's cost is the erasure of the difference between "no rule was found" and "a rule
  with a rate of zero"; where the absence requires a decision, an explicit result type is
  preferred.

## Next Step

Across nine lessons, both the gain and the cost of nine patterns were counted, and at the scales
where the measurements were taken — strategy at four tariffs, the chain at seven rules, the
mediator at four fields — the gain exceeded the cost every time. The numbers show that a threshold
exists, but what lies below that threshold has not yet been measured. What comes out when a
strategy with a single implementation, an event with a single observer, a template with a single
step, a two-state state machine, and a chain with a single handler are tested with the same
measures? The next lesson runs the same measures used across the nine lessons, this time against
the pattern's favor: the number of files added, the level of indirection, and the number of calls
that must be traced, in exchange for zero gain.
