Emoji AST Interpreter Scope Stage - Part 4
May 21, 2026 ยท 22:09 UTC

Real languages let names stand for things, and up to now every expression has been a throwaway calculation that vanishes the very moment it finishes, so this part gives the emoji language a real memory by adding variables, and along the way it also grows a unary minus and a ternary choice so that expressions can finally start making small decisions of their own instead.
Variables need somewhere to live, and that home is a scope, which here is just a plain map from a name to a value, so evaluating a variable simply means looking its own name straight up in that scope, while assigning one means writing the value back.
These features all share one theme, the tree gains a few new node types, and the evaluator gains a matching branch for each one.
Note
This is a five-part series building one emoji language end to end.
- Part 1 - Lexer
- Part 2 - Parser
- Part 3 - Walker
- Part 4 - Scope (you are here)
- Part 5 - REPL
Growing the Node Types
Adding a feature to this kind of language always follows the same familiar rhythm, first the node union gains a brand new shape, then the parser learns to produce it correctly, and finally the evaluator learns to handle it too, and once that rhythm becomes second nature the language can grow almost indefinitely without ever redesigning its small and careful core.
For this part four new node shapes arrive, an identifier for reading a variable, an assignment for writing one back, a unary node for negation, and a ternary node for choosing between two different branches based on a condition it evaluates first.
Each new shape is small on its own, but together they turn a plain calculator into something that starts to resemble a language.
// src/types.ts
export type Node =
| { type: 'number'; value: string }
| { type: 'ident'; name: string }
| { type: 'assign'; name: string; value: Node }
| { type: 'unary'; op: string; operand: Node }
| { type: 'ternary'; test: Node; then: Node; else: Node }
| { type: 'binary'; op: string; left: Node; right: Node }
export type Scope = Record<string, number>
Evaluating With a Scope
Evaluation now takes a second argument, the scope, and it carefully threads that same scope through every single recursive call so that a variable assigned deep inside one branch is still fully visible to the branches evaluated after it, which is exactly the kind of shared memory that makes naming genuinely useful in the first place rather than a decorative gimmick.
An identifier node reads its name straight from the scope and errors loudly the moment the name was never assigned, because a silent undefined slipping through here would quietly turn a typo into a completely baffling wrong answer much later on.
Assignment nodes evaluate the right side, store the result under that name, and return the value so assignments can chain freely.
// src/eval.ts
import type { Node, Scope } from './types.ts'
export function evaluate(node: Node, scope: Scope): number {
switch (node.type) {
case 'number':
return FRUIT_VALUE[node.value]
case 'ident': {
if (!(node.name in scope)) {
throw new Error(`Undefined variable: ${node.name}`)
}
return scope[node.name]
}
case 'assign': {
const value = evaluate(node.value, scope)
scope[node.name] = value
return value
}
// ... unary, ternary, and binary below
}
}
Adding Unary and Ternary
Unary minus is the smallest addition of them all, the parser recognizes a leading minus that has no left operand and simply wraps whatever follows it in a unary node, and the evaluator then negates the value of its single child, which finally lets an expression like negative apple mean exactly what everyone naturally expects it to mean without any awkward workarounds.
Ternary nodes are more interesting because they introduce the first real control flow, evaluating a condition and then picking one of two branches, so the branch not chosen is never run, and that laziness matters once branches gain side effects.
A ternary node therefore holds three separate children, a single test to check, a consequent branch, and an alternate branch.
case 'unary':
return -evaluate(node.operand, scope)
case 'ternary':
return evaluate(node.test, scope) !== 0
? evaluate(node.then, scope)
: evaluate(node.else, scope)
case 'binary': {
const left = evaluate(node.left, scope)
const right = evaluate(node.right, scope)
return APPLY[node.op](left, right)
}
Teaching the Lexer and Parser
Those evaluator cases shown above only work once the lexer and parser actually produce these new nodes, so both of the earlier stages need a small extension, the lexer must recognize letters as an identifier and treat the equals sign, the comparison signs, the question mark, and the colon as operators, while everything else about it stays exactly the same as before.
Parsing grows two matching pieces, a primary that reads an identifier and then checks whether an equals sign follows to decide between a plain read and an assignment, plus a leading minus that becomes a unary node, and a fresh ternary layer that sits just above the binary layer and wraps a test with its two branches whenever a question mark appears right after it.
Comparison operators get the lowest precedence so a test like banana above apple resolves fully before the ternary looks at it.
// src/lexer.ts (extend the sets and add identifiers)
const OPERATORS = new Set(['+', '-', '*', '/', '(', ')', '=', '>', '<', '?', ':'])
const IDENT = /[a-zA-Z_]/
// inside tokenize(), before the throw, handle a run of letters:
if (IDENT.test(char)) {
let name = char
while (IDENT.test(source[pos + 1] ?? '')) {
name += source[++pos]
}
tokens.push({ kind: 'ident', value: name })
continue
}
// src/parser.ts (extend PRECEDENCE, primary, and add a ternary layer)
const PRECEDENCE: Record<string, number> = {
'>': 0,
'<': 0,
'+': 1,
'-': 1,
'*': 2,
'/': 2
}
function parsePrimary(): Node {
const token = next()
if (token.kind === 'ident') {
if (peek().value === '=') {
next() // consume '='
return { type: 'assign', name: token.value, value: parseTernary() }
}
return { type: 'ident', name: token.value }
}
if (token.value === '-') {
return { type: 'unary', op: '-', operand: parsePrimary() }
}
// ... number and parenthesis cases stay the same
}
function parseTernary(): Node {
const test = parseExpression(0)
if (peek().value === '?') {
next() // consume '?'
const then = parseTernary()
next() // consume ':'
return { type: 'ternary', test, then, else: parseTernary() }
}
return test
}
// the top-level parse now starts at the ternary layer
// return parseTernary()
Seeing It All Come Together
With variables and a ternary in place, the language can suddenly express real intent, a banana can be assigned to a name, that name reused in a later expression, and a result even chosen based on whether some earlier value came out positive, which is a genuine leap from a pocket calculator toward something that can hold a short train of thought across several steps.
Notice how little new machinery each feature actually required, no stage was rewritten, every addition slotted cleanly into the node union, parser, and evaluator, and that extensibility is the clearest signal the separation of stages was worth it.
The language can now remember values and make decisions, and the final part turns it into a real tool anyone can actually talk to.
// main.ts (run now threads a shared scope through every call)
function run(source: string, scope: Scope): number {
return evaluate(parse(tokenize(source)), scope)
}
const scope: Scope = {}
run('x = ๐', scope) // store 2 under x
run('x + ๐', scope) // 2 + 1 = 3
run('x > ๐ ? ๐ : ๐', scope) // x is 2, so ๐ = 3


