AST nodes being evaluated

Making a Language: The Evaluator

Building Spark's evaluator — environments, closures, operator overloading, return signals, and error reporting.

May 18, 2026#typescript#compilers

In the previous part, we built the parser that turns tokens into an AST. Now we get to the fun part: making it actually run.

Environments: Managing Scope

Variables don't float in space. They live in environments. An environment is just a map of names to values, with a pointer to the parent scope.

environment.ts
1export function createEnvironment(
2  parent: Environment | null = null,
3): Environment {
4  return { variables: new Map(), parent };
5}

Creates a fresh scope. Pass a parent and the new environment links to it. Variable lookups will walk up that chain.

environment.ts
1export function defineVar(
2  env: Environment,
3  name: string,
4  value: RuntimeValue,
5): RuntimeValue {
6  env.variables.set(name, value);
7  return value;
8}

Sticks a variable into the current environment. No chain-walking; let always binds in the innermost scope.

environment.ts
1export function getVar(
2  env: Environment,
3  name: string,
4  line: number,
5  col: number,
6): RuntimeValue {
7  let current: Environment | null = env;
8  while (current) {
9    const value = current.variables.get(name);
10    if (value !== undefined) return value;
11    current = current.parent;
12  }
13  throw new RuntimeError(`Undefined variable "${name}"`, line, col);
14}

Walks up from the current environment through each parent until it finds the variable. If it reaches the top with no match, it throws a RuntimeError with source position. There is no silent undefined nonsense.

environment.ts
1export function setVar(
2  env: Environment,
3  name: string,
4  value: RuntimeValue,
5  line: number,
6  col: number,
7): RuntimeValue {
8  let current: Environment | null = env;
9  while (current) {
10    if (current.variables.has(name)) {
11      current.variables.set(name, value);
12      return value;
13    }
14    current = current.parent;
15  }
16  throw new RuntimeError(`Undefined variable "${name}"`, line, col);
17}

Same walk-up logic as getVar, but for reassignment. It only writes to the scope where the variable was originally defined. Shadows are respected, not overwritten.

The evaluate Function

evaluator.ts
1export function evaluate(program: Program): {
2  output: string[];
3  error?: string;
4} {
5  const output: string[] = [];
6  const env = createEnvironment();
7
8  defineVar(env, "say", {
9    type: "native",
10    name: "say",
11    fn: (args) => {
12      const str = args.map(formatValue).join(" ");
13      output.push(str);
14      return { type: "null" };
15    },
16  });
17
18  try {
19    for (const stmt of program.body) {
20      evaluateStatement(stmt, env);
21    }
22  } catch (e) {
23    if (e instanceof RuntimeError) {
24      return { output, error: e.format(originalSource) };
25    }
26    throw e;
27  }
28
29  return { output };
30}

The entry point. It creates the global environment, registers the built-in say function (which captures output by closure), then runs every statement in the program. If any RuntimeError escapes, it's caught and formatted with source context so the process never hard-crashes.

evaluator.ts
1let originalSource = "";
2
3export function setSource(source: string) {
4  originalSource = source;
5}

The parser calls setSource before evaluation so runtime errors can display the offending line with a pointer.

Statement Evaluation

evaluator.ts
1function evaluateStatement(stmt: Statement, env: Environment): RuntimeValue {
2  switch (stmt.kind) {
3    case "LetStatement":
4      return evaluateLet(stmt, env);
5    case "IfStatement":
6      return evaluateIf(stmt, env);
7    case "FunctionDeclaration":
8      return evaluateFn(stmt, env);
9    case "ReturnStatement":
10      throw new ReturnSignal(evaluateExpressionOrNull(stmt.value, env));
11    case "ExpressionStatement":
12      return evaluateExpression(stmt.expression, env);
13    default:
14      return { type: "null" };
15  }
16}

Dispatches on the AST node's kind. Each statement type has its own handler. ReturnStatement doesn't return. Instead it throws a signal that unwinds the call stack.

evaluator.ts
1function evaluateLet(stmt: LetStatement, env: Environment): RuntimeValue {
2  const value = evaluateExpression(stmt.value, env);
3  return defineVar(env, stmt.name, value);
4}
5
6function evaluateIf(stmt: IfStatement, env: Environment): RuntimeValue {
7  const condition = evaluateExpression(stmt.condition, env);
8  const boolVal = isTruthy(condition);
9
10  if (boolVal) {
11    return evaluateBlock(stmt.consequent, env);
12  } else if (stmt.alternate) {
13    if (
14      stmt.alternate.length === 1 &&
15      stmt.alternate[0].kind === "IfStatement"
16    ) {
17      return evaluateIf(stmt.alternate[0] as IfStatement, env);
18    }
19    return evaluateBlock(stmt.alternate, env);
20  }
21
22  return { type: "null" };
23}
24
25function evaluateFn(
26  stmt: FunctionDeclaration,
27  env: Environment,
28): RuntimeValue {
29  const fn: RuntimeValue = {
30    type: "function",
31    name: stmt.name,
32    params: stmt.params,
33    body: stmt.body,
34    closure: env,
35  };
36  return defineVar(env, stmt.name, fn);
37}
  • evaluateLet evaluates the initializer and binds the result in the current scope.
  • evaluateIf checks truthiness (not strict boolean), then runs the matching branch. else if chains are handled by checking if the alternate is a single IfStatement and recursing.
  • evaluateFn creates a function object that captures the current environment as its closure. This is what enables lexical scoping.
evaluator.ts
1function evaluateBlock(stmts: Statement[], env: Environment): RuntimeValue {
2  const blockEnv = createEnvironment(env);
3  let result: RuntimeValue = { type: "null" };
4  for (const stmt of stmts) {
5    try {
6      result = evaluateStatement(stmt, blockEnv);
7    } catch (e) {
8      if (e instanceof ReturnSignal) throw e;
9      throw e;
10    }
11  }
12  return result;
13}
14
15class ReturnSignal {
16  constructor(public value: RuntimeValue) {}
17}

A block creates a child scope and runs each statement in order. The ReturnSignal class is the mechanism for return. It is thrown by ReturnStatement and caught by the function call handler. evaluateBlock re-throws it so it propagates up through nested blocks.

Expression Evaluation

evaluator.ts
1function evaluateExpression(expr: Expression, env: Environment): RuntimeValue {
2  switch (expr.kind) {
3    case "NumberLiteral":
4      return { type: "number", value: expr.value };
5    case "StringLiteral":
6      return { type: "string", value: expr.value };
7    case "BoolLiteral":
8      return { type: "boolean", value: expr.value };
9    case "Identifier":
10      return getVar(env, expr.name, 0, 0);
11    case "BinaryExpression":
12      return evaluateBinary(expr, env);
13    case "ArrayLiteral":
14      return evaluateArray(expr, env);
15    case "IndexExpression":
16      return evaluateIndex(expr, env);
17    case "CallExpression":
18      return evaluateCall(expr, env);
19    case "Assignment":
20      return evaluateAssignment(expr, env);
21    default:
22      return { type: "null" };
23  }
24}

Literals become runtime values directly. Identifiers look up variables, binary expressions recurse into evaluateBinary, calls dispatch to evaluateCall, and so on. Same pattern as evaluateStatement: one handler per kind.

Binary Operators

evaluator.ts
1function evaluateBinary(expr: BinaryExpression, env: Environment): RuntimeValue {
2  if (expr.operator === "!") {
3    const right = evaluateExpression(expr.right, env);
4    return { type: "boolean", value: !isTruthy(right) };
5  }
6  // ...evaluate left/right, then switch on operator...

The ! (not) operator is special. It is an example of unary in practice, stored as binary in the AST. It negates truthiness rather than requiring a boolean, so !0 is yes and ![] is no.

evaluator.ts
1    case "..":
2      if (left.type === "number" && right.type === "number") {
3        const arr: RuntimeValue[] = [];
4        for (let i = left.value; i <= right.value; i++) {
5          arr.push({ type: "number", value: i });
6        }
7        return { type: "array", value: arr };
8      }
9      throw new RuntimeError("Range requires numbers", 0, 0);

The range operator .. (inspired by Rust) creates an array of consecutive numbers. 1..5 evaluates to [1, 2, 3, 4, 5].

evaluator.ts
1    case "+": {
2      if (left.type === "number" && right.type === "number")
3        return { type: "number", value: left.value + right.value };
4      if (left.type === "string" && right.type === "string")
5        return { type: "string", value: left.value + right.value };
6      if (left.type === "array" && right.type === "array")
7        return { type: "array", value: [...left.value, ...right.value] };
8      return { type: "string", value: formatValue(left) + formatValue(right) };
9    }

The + operator is overloaded: adds numbers, concatenates strings, merges arrays, and coerces mixed types to strings. Other operators like -, *, /, and comparisons all enforce numeric types.

evaluator.ts
1function evaluateCall(expr: CallExpression, env: Environment): RuntimeValue {
2  const callee = evaluateExpression(expr.callee, env);
3
4  if (callee.type === "native") {
5    const args = expr.args.map((a) => evaluateExpression(a, env));
6    return callee.fn(args);
7  }
8
9  if (callee.type === "function") {
10    const args = expr.args.map((a) => evaluateExpression(a, env));
11    const fnEnv = createEnvironment(callee.closure);
12    callee.params.forEach((param, i) => {
13      defineVar(fnEnv, param, args[i] ?? { type: "null" });
14    });
15    try {
16      return evaluateBlock(callee.body, fnEnv);
17    } catch (e) {
18      if (e instanceof ReturnSignal) return e.value;
19      throw e;
20    }
21  }
22
23  throw new RuntimeError("Cannot call non-function", 0, 0);
24}

Native functions (like say) call directly into JavaScript. User-defined functions create a new environment chained to their closure (not the call site), bind arguments to parameter names, then run the body. The try/catch intercepts ReturnSignal to extract the return value.

Truthiness and Equality

evaluator.ts
1function isTruthy(val: RuntimeValue): boolean {
2  switch (val.type) {
3    case "null":
4      return false;
5    case "boolean":
6      return val.value;
7    case "number":
8      return val.value !== 0;
9    case "string":
10      return val.value !== "";
11    case "array":
12      return val.value.length > 0;
13    default:
14      return true;
15  }
16}

Zero, empty string, empty array, and null are falsy. Everything else, including functions, is truthy. This lets you write when (items) instead of when (items.length > 0).

evaluator.ts
1export function formatValue(val: RuntimeValue): string {
2  switch (val.type) {
3    case "null":
4      return "null";
5    case "number":
6      return String(val.value);
7    case "string":
8      return val.value;
9    case "boolean":
10      return val.value ? "yes" : "no";
11    case "array":
12      return `[${val.value.map(formatValue).join(", ")}]`;
13    case "function":
14      return `<func ${val.name}>`;
15    case "native":
16      return `<native ${val.name}>`;
17  }
18}

Controls how values appear in say output. Booleans display as yes/no instead of true/false. A small language personality choice. Arrays are recursively expanded.

In the final part, we'll wire everything up, run some complete Spark programs, and explore how you can extend the language yourself.