← Design patterns
Behavior Rules, meaning, and context

Interpreter

Give a small language a meaning.

Maya wants a saved support view: tickets that are open, and either urgent or assigned to her. Rian wants to use that same view for his own work.

The tickets change. The viewer changes. The rule can stay: open AND (urgent OR mine). How does that saved structure become an answer?

TypeScriptGoOne expression language · across the comparison

01 / When the rule becomes data

One fixed filter can stay a function.

For the first support view, a normal predicate is a good fit. Check the ticket’s status, priority, and assignee, then return a boolean. The rule lives in application code, and changing it means editing that function.

Now let people build their own views. One wants urgent tickets that belong to somebody else. Another wants open tickets that are urgent or theirs. They can nest groups, save them, and reopen the same rule later. Writing a new function for every combination someone might build no longer fits the product.

Interpreter represents the expressions of a small language and gives each form a rule for evaluating itself in a context. An atom reads a fact. A compound expression asks its children for answers and combines them. The saved structure determines which calculation happens.

Our language is small on purpose: three conditions and three logical forms, evaluated by walking the tree to answer one ticket-filtering question.

02 / Choose the vocabulary and its meaning

The parentheses are part of the rule.

Read open AND (urgent OR mine) from the outside in. The outer AND requires open to be true. Its other operand is a whole expression: urgent OR mine. That inner expression can be answered the same way as a single condition.

The complete vocabulary of the filter language
ExpressionMeaning in one evaluation
open · urgentRead the ticket’s status or priority and return true or false.
mineCompare its assignee with the supplied viewer. An unassigned ticket is false. A missing viewer produces an error.
left AND rightEvaluate left first. False decides the result; true requires right. An error propagates.
left OR rightEvaluate left first. True decides the result; false requires right. An error propagates.
NOT expressionNegate a successful boolean. Preserve an error.

Move the group to get (open AND urgent) OR mine, and mine becomes an alternative to the entire left group. A closed ticket assigned to Maya can now match. Both rules are valid. They express different requests.

The structure is an expression tree, the kind of tree a parser would produce from written syntax. The leaves are atoms such as mine. AND, OR, and NOT contain smaller expressions. Each node means something in the language; a tree by itself does not provide that meaning.

The context supplies the facts for this run: a ticket and a viewer ID. Mine keeps no previous user or answer. Give the same rule another viewer, and it may produce another result without changing its structure.

BUILD → STRUCTURE

Make the expression

A visual editor constructs nodes directly. A text parser could construct the same kind of tree from written syntax.

INTERPRET → MEANING

Supply the context

Each expression reads or combines values according to its language rule.

CALLER → APPLICATION

Use the answer

The saved-view service includes matching tickets or reports an evaluation failure.

Our lab starts with the first path: a builder whose displayed expression is printed from the tree. Turning text into a valid tree and giving that tree meaning are separate responsibilities.

03 / Follow a child’s answer

An AND contains expressions, not just conditions.

Start with the common operation, interpret. AND asks its left expression for a result. If that result already determines the answer, it returns. Otherwise it interprets the right expression. Either child can be another compound expression.

TypeScript and Go give each form its own implementation. The meaning, evaluation order, and failures are the same in both.

The practical view adds a fresh evaluation report and a search caller. The caller owns the list: it appends IDs for true results, skips false ones, and aborts the list on an error. That last choice is this application’s policy.

An expression interprets itself in a context. Atoms read ticket or viewer data; AND delegates to children and stops after false or an error. The implementations represent grammar forms with their native type mechanisms.

TypeScriptReading
filter.ts
export interface Expression {
	interpret(context: Context, trace: Step[], path: string): Result;
}
export type AtomName = 'open' | 'urgent' | 'mine';
export class Atom implements Expression {
	readonly name: AtomName;
	constructor(name: AtomName) {
		this.name = name;
	}
	interpret(context: Context, trace: Step[], path: string): Result {
		const { ticket, viewer } = context;
		let result: Result;
		switch (this.name) {
			case 'open':
				result = value(ticket.status === 'open');
				break;
			case 'urgent':
				result = value(ticket.priority === 'urgent');
				break;
			case 'mine':
				result =
					viewer === null
						? { ok: false, error: 'missing-viewer' }
						: value(ticket.assignee === viewer);
				break;
			default:
				throw new Error('unsupported authored atom');
		}
		return record(trace, path, this.name, result);
	}
}
export class And implements Expression {
	readonly left: Expression;
	readonly right: Expression;
	constructor(left: Expression, right: Expression) {
		this.left = left;
		this.right = right;
	}
	interpret(context: Context, trace: Step[], path: string): Result {
		const left = this.left.interpret(context, trace, path + '.L');
		// Errors propagate. False already decides AND; right is not evaluated.
		const result =
			!left.ok || !left.value ? left : this.right.interpret(context, trace, path + '.R');
		return record(trace, path, 'and', result);
	}
}
GoAlongside
filter.go
type Expression interface {
	Interpret(Context, *[]Step, string) (bool, error)
}
type Atom string

func (a Atom) Interpret(ctx Context, trace *[]Step, path string) (bool, error) {
	var value bool
	var err error
	switch a {
	case "open":
		value = ctx.Ticket.Status == "open"
	case "urgent":
		value = ctx.Ticket.Priority == "urgent"
	case "mine":
		if ctx.Viewer == nil {
			err = ErrMissingViewer
		} else {
			value = ctx.Ticket.Assignee != nil && *ctx.Ticket.Assignee == *ctx.Viewer
		}
	default:
		panic("unsupported authored atom")
	}
	return record(trace, path, string(a), value, err)
}

type And struct{ Left, Right Expression }

func (a And) Interpret(ctx Context, trace *[]Step, path string) (bool, error) {
	value, err := a.Left.Interpret(ctx, trace, path+".L")
	// Errors propagate. False already decides AND; right is not evaluated.
	if err == nil && value {
		value, err = a.Right.Interpret(ctx, trace, path+".R")
	}
	return record(trace, path, "and", value, err)
}
Reading the TypeScript

Expression is a shared operation contract. An And stores two Expressions, so its children can be Atom, Or, Not, or another And. This recursive relationship lets the rule grow without making the search caller know every arrangement.

Result is a discriminated union. When ok is true, there is a boolean value; otherwise there is an error. The AND expression checks success before reading that value. An error is never treated as a false operand.

The readonly fields describe the intended API without deeply freezing the graph at runtime. The supplied expressions read their context and retain no result; the owner must keep the graph and context stable during a run.

Reading the Go

The Expression interface declares Interpret, and Atom, And, Or, and Not implement it. A struct field typed as Expression can hold another compound expression. The nil cases of an interface are not made safe by that declaration; the authored trees always supply valid children.

Interpret returns (bool, error). The boolean is meaningful only when the error is nil. AND and OR return immediately after an error rather than letting the boolean’s zero value disguise it as a non-match.

A *string distinguishes no viewer from a supplied ID. Comparing IDs dereferences non-nil pointers. The model uses nil for missing, preserves strings exactly, and treats exported tree fields as read-only by convention.

04 / Predict, change, observe

A skipped branch has no answer.

Begin with T-103, a closed ticket assigned to Maya. With the default rule, open returns false. That is enough for AND, so the whole OR group is skipped. You know the complete rule is false; you have not learned what its skipped nodes would return.

Move the parentheses to the left pair and run again. The left AND still returns false, but the outer OR now asks mine. Compare the result and the recorded return sequence.

SAVED SEARCH / RULE + CONTEXT → ANSWER

Move the parentheses. Keep the tickets.

Inspect T-103, a closed ticket assigned to Maya. Predict its result, run the rule, then group the left pair and try again.

Change the conditions and operators

A, B, and C are three occurrences in the rule. The same condition can appear more than once. Operators combine only boolean results.

THE BUILT EXPRESSION(open AND (urgent OR mine))

This text is printed from the tree the controls build.

Inspect a ticket

The rule, interpreted

The same expression tree receives a fresh ticket and viewer for each evaluation.

Expression tree

T-103 · viewer: maya

  1. RuleANDAwaiting run
  2. LeftOPENAwaiting run
  3. RightORAwaiting run
  4. LeftURGENTAwaiting run
  5. RightMINEAwaiting run

Returned values for T-103

Evaluated nodes0
Not evaluated—

Run the saved search to inspect this ticket. Leaf results return before the operators that use them.

Nodes show their returned result. Not evaluated means execution skipped that occurrence, which is different from false. The replay steps through completed return records.
Read the full return trace

No evaluation recorded yet.

Switch the viewer to Rian without changing the tree. Then try no viewer. An urgent open ticket can succeed without reaching mine, while a normal open ticket needs that missing context. The search caller reports an error instead of quietly publishing the matches it found before the failure.

NOT applies to the entire built rule. The extra condition controls let you repeat atoms and change operators. Try an expression that matches nothing: an empty filtered list is a successful result when every evaluation returned false.

The lab runs the TypeScript interpreter shown above.

05 / Read what the rule means

Separate false, skipped, and failed.

These three observations can all leave a ticket out of a displayed list, but they tell you different things about the evaluation.

A closed ticket belongs to Maya. Why does (open AND urgent) OR mine include it?

06 / Give the language an owner

The saved view keeps the rule. Each run brings the facts.

A saved-view service loads a versioned rule definition, validates it, and constructs a reusable expression tree. When a person opens that view, the service supplies the current viewer and tickets. Each interpretation gets fresh trace state; the tree itself does not remember who ran it last.

The editor owns construction. The language module owns what open, mine, AND, and the other forms mean. The caller owns loading, persistence, error presentation, and the matching-list policy. A tree is a useful boundary because each of those jobs can work with the same explicit structure.

Suppose you add overdue. Define which deadline and clock it uses, how a missing deadline behaves, and which types are valid. Then add the expression form, its editor and validation support, and equivalent behavior cases. The search loop can keep asking expressions for results. The language has still grown, so its surrounding contract needs work.

What a saved rule needs beyond this example

Validate before interpreting. These samples accept authored, finite, acyclic trees and well-formed ticket data. TypeScript’s typed builder adapter and Go’s Spec adapter are not validators for outside JSON. A loader must check supported names, child counts, value types, and the saved format’s version. A branch that happens to be skipped for today’s ticket can still contain an invalid rule.

Keep absence and failure explicit. No assignee means mine is false for a supplied viewer. No viewer means mine cannot answer if reached. Our language propagates that error left to right instead of evaluating further for a result that might mask it.

Bound the work. A run visits at most the tree’s nodes, with recursion following its depth. A large ticket list repeats that work. Set size and depth limits before admitting rules. Its diagnostic path strings and trace also add allocation work that a production hot path may not need.

Keep facts stable and reuse deliberate. The supplied operations perform no I/O or mutation. Adding a network-backed condition introduces failure, latency, cancellation, and consistency decisions. Avoid caching a boolean on a reusable node; a result depends on the ticket, viewer, and any other declared context.

Keep filtering and access control separate. A client preview should receive only records the user is already allowed to read. A saved filter chooses among those records; authorization stays on the server even when evaluation moves into a browser.

Choose where evaluation belongs. In-memory interpretation is useful for a small local set. A large database may need a query compiled from the same validated rule. That backend must preserve grouping, missing-value behavior, and error semantics.

Build UIs?The browser answers every @container rule against a container it may not find, and a form whose fields appear by rule makes that decision yours.

Where it already is in your components

If you have added container-type: inline-size to a wrapper because an @container rule did nothing, you already follow a rule that comes from this pattern. The browser interprets the condition in @container not (width >= 30rem) for each element it styles, against that element’s query container, so one rule in a component’s stylesheet gives a card in a sidebar and a card in the main column different answers. The context can be missing. CSS Conditional Rules Level 5 says: “If no ancestor is an eligible query container, then the container query is unknown for that element.”

not does not rescue it. Media Queries Level 4 says “The negation of unknown is unknown,” and that “unknown” must be converted to “false” wherever a yes-or-no answer is needed. In Chromium 153 the card below stacked at 240 pixels only while its wrapper set container-type. Without that line it stayed in a row at every width, and a double not did not apply either. CSS gives missing context a third value where our saved search returns an error; in both, NOT cannot turn a missing fact into a match.

PlanCard.svelte
<script lang="ts">
	let { plan, price }: { plan: string; price: string } = $props();
</script>

<div class="slot">
	<article class="card">
		<h3>{plan}</h3>
		<p>{price}</p>
	</article>
</div>

<style>
	.slot {
		/* Remove this line and no container answers the query below. */
		container-type: inline-size;
	}
	.card {
		display: flex;
		justify-content: space-between;
	}
	@container not (width >= 30rem) {
		.card {
			flex-direction: column;
		}
	}
</style>

The container also has to answer every feature the condition names. The browser picks from containers “established as a valid query container for all the container features in the <container-query>”, so against an inline-size wrapper, @container (width < 400px) or (height > 100px) never applied in Chromium 153, even while the width half was true. Grouping belongs to the grammar as well: a condition is a run of and or a run of or, so Chromium dropped @container (width < 400px) and (width > 10px) or (height > 1px) from the stylesheet until parentheses chose the grouping.

When you have to own it

Now picture a checkout form that loads its fields from a schema your team edits: show “VAT number” when the account type is business and the country is not the United States. Each field carries a small expression, and every change to the form evaluates it again against the current answers. Nobody wrote a parser; the schema already stores the tree.

A buyer picks a business account before choosing a country, so NOT (country is US) reaches a question with no answer. Treat the missing answer as false, and NOT turns it into true: the VAT field appears for a country nobody has chosen. Return unknown and hide the field on unknown, as CSS does, and it waits for the answer. Return an error, as the saved search does, and the form has to decide what an error shows. Pick one, write it beside the vocabulary, and give the NOT case a shared example, so the server that checks the submitted form reaches the same answer.

07 / Recognize rules you already pass around

A configuration value can carry a decision.

Some APIs accept a structure or expression together with the data it should evaluate. Their syntax and guarantees vary, but separating a reusable rule from a particular run is a useful connection.

JsonLogic makes the rule data.

JsonLogic documents nested operator objects that can be stored, built from interface actions, and applied to supplied data. Its rule/data API is a concrete example of this separation. It has its own vocabulary and value semantics.

Read the JsonLogic rule and data examples ↗

CEL separates preparation from evaluation.

The Common Expression Language documentation distinguishes parsing, checking, and evaluation. It describes preparing an expression and evaluating it repeatedly with different bindings. That is a fuller version of the lifecycle this saved view needs.

Read CEL’s expression-processing phases ↗

08 / Make the call

Keep the language as small as the job allows.

Interpreter is useful when combinations of rules must be represented, nested, stored, explained, or evaluated against changing contexts. A saved filter, an eligibility expression, or a small formula language can create that pressure.

Keep a predicate function for one fixed filter. Keep a few named policies when callers only choose from that fixed menu. A language adds a vocabulary, validation, compatibility, diagnostics, and evaluation semantics; nesting is useful only when the product needs it.

Nearby ideas and the responsibility they emphasize
NeedUseful direction
Run one known checkA predicate function.
Select one complete policyStrategy; callers choose a policy rather than compose a language.
Treat leaves and groups uniformlyComposite; this expression tree has that structure, while Interpreter supplies its language meaning.
Add operations such as formatting or analysis to the treeVisitor or another traversal mechanism. Visitor keeps an operation outside the node types; here each expression interprets itself.
Support a substantial user expression languageEvaluate an established engine’s syntax, type system, limits, and embedding contract.

Adding multiplication, functions, collections, dates, and custom operators can turn a neat little rule system into a language product. At that point, good error messages and stable semantics matter as much as the small recursive evaluator that started it.

09 / Take the idea with you

Explain why mine changes without changing the rule.

Try it without the pattern name: the saved structure describes a question, each expression knows how to answer its part, and the context supplies the facts for this run. The caller decides what the resulting boolean or error means for the application.

Then add one requirement: allow “due within three days.” Which new facts belong in the context? Where would you define date and missing-value semantics? Which changes belong to the builder, the validator, the interpreter, and the search caller?

Connections to follow nextRelated lessons

Composite explains the recursive relationship between atoms and groups. Visitor keeps other tree operations outside the node types; each of these expressions interprets itself. Strategy is useful when one chosen policy is enough. Interpreter earns its place when the rule itself needs an explicit language.