cf.completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Build a tiny JavaScript interpreter

Build a small interpreter for a piece of JavaScript: a tokenizer, a recursive-descent parser and a tree-walking evaluator, with scopes and closures.

By the end, you can
  • 01
    Build the three partsWrite a tokenizer, a parser and an evaluator, and say what each one takes and gives back.
  • 02
    Explain precedence and scopesShow why * ends up lower in the tree than +, and how a closure keeps the scope it was made in.
  • 03
    Compare with real enginesSay what V8 and other engines do that a tree-walking interpreter doesn't, such as bytecode and JIT compilers.

What an interpreter does

An interpreter is a program that runs other programs. In this lesson you build one for a small piece of JavaScript. We'll call it TinyJS: numbers, strings, let, + - * /, comparisons, if, functions and a built-in print. It is three small files you can read in full.

Run a TinyJS programPop out in the code editor (opens in a new tab)JavaScript
import { run } from "./evaluator.js";

run(`
function factorial(n) {
  if (n <= 1) {
    return 1;
  }
  return n * factorial(n - 1);
}
let price = 4;
print("total", price * 3 + 1);
print("5! =", factorial(5));
`);

Lines 4 to 12 are a TinyJS program, in a string. Line 3 passes it to run, from the interpreter's evaluator.js file. It prints total 13 and 5! = 120, the same as JavaScript would.

run does three things, one after another. The tokenizer splits the text into words and symbols. The parser builds a tree from them. The evaluator walks the tree and works out values.

Four rows joined by arrows. The text print(1 + 2 * 3); goes through the tokenizer and becomes nine tokens. The parser turns them into the tree (call print (+ 1 (* 2 3))). The evaluator walks the tree and prints 7.print(1 + 2 * 3);tokenizerprint(1+2*3);parser(call print (+ 1 (* 2 3)))evaluatorprints 7text → tokens → tree → values
The three parts of the interpreter, one after another. Each part takes what the one before it made: text, then tokens, then a tree. Only the evaluator runs anything.
Real-life analogyFollowing a recipe

Picture reading a recipe in a language you're still learning. First you pick out the words. Then you work out which words belong to which step. Only then do you start cooking, keeping track of what is already in the bowl.

In real life: Picking out the words and numbers
In JavaScript: The tokenizer: text into tokens
In real life: Working out which words make up each step
In JavaScript: The parser: tokens into a tree
In real life: Doing the steps, in order
In JavaScript: The evaluator: walking the tree
In real life: Remembering what's already in the bowl
In JavaScript: A scope: names and their values
The main idea

An interpreter turns text into tokens, tokens into a tree, and then walks the tree to work out values. Only that last part runs anything.

Step 1: split the text into tokens

A token is the smallest piece of code that means something: a number, a string, a name, a keyword such as let, or a symbol such as =. The tokenizer turns the text into a flat list of tokens. Here is all of it:

tokenizer.jsJavaScript
const pattern = /(\d+(?:\.\d+)?)|("[^"]*")|([A-Za-z_]\w*)|(===|!==|<=|>=|[-+*/<>=(){},;])/y;
const keywords = new Set(["let", "function", "return", "if", "else"]);

export function tokenize(source) {
  const tokens = [];
  let position = 0;
  while (position < source.length) {
    if (/\s/.test(source[position])) {
      position += 1;
      continue;
    }
    pattern.lastIndex = position;
    const match = pattern.exec(source);
    if (!match) throw new SyntaxError(`Unexpected "${source[position]}" at ${position}`);
    const [text, number, string, name] = match;
    if (number) tokens.push({ type: "number", value: Number(number) });
    else if (string) tokens.push({ type: "string", value: string.slice(1, -1) });
    else if (name) tokens.push({ type: keywords.has(name) ? "keyword" : "name", value: name });
    else tokens.push({ type: "punct", value: text });
    position = pattern.lastIndex;
  }
  return tokens;
}

Line 1 is one regular expression with four groups: numbers, strings, names, and punctuation. The y flag makes it sticky: it only matches exactly at lastIndex, never further ahead.

Lines 8 to 11 skip spaces and line breaks. Line 12 says where to match, line 13 matches, and line 14 throws if no group fits. Lines 16 to 19 make a token from whichever group matched; line 18 tells keywords from other names.

Tokenize one linePop out in the code editor (opens in a new tab)JavaScript
import { tokenize } from "./tokenizer.js";

const tokens = tokenize("let total = price * 2;");
console.log(tokens.map((token) => token.type + ":" + token.value).join(" "));

Line 3 tokenizes let total = price * 2;, and line 4 prints each token as type:value: keyword:let name:total punct:= name:price punct:* number:2 punct:;. Seven tokens. Nothing checks yet that they make sense together.

The order inside line 1's last group matters: === comes before =, because the first choice that matches wins. Exercise 6 shows what goes wrong otherwise. Real engines write their scanner by hand; the scanner and tokens lesson shows V8's.

Step 2: build a tree

The parser reads the tokens and builds a tree of nodes, one for each part of the program. This one is a recursive-descent parser: one function for each grammar rule, and the functions call each other.

parser.jsJavaScript
import { tokenize } from "./tokenizer.js";

export function parse(source) {
  const tokens = tokenize(source);
  let index = 0;
  let functionDepth = 0;
  const peek = () => tokens[index];
  const next = () => tokens[index++];
  const isNext = (value) => peek()?.type !== "string" && peek()?.value === value;
  const found = () => (peek() ? `"${peek().value}"` : "the end");
  function expect(value) {
    if (!isNext(value)) throw new SyntaxError(`Expected "${value}" but found ${found()}`);
    next();
  }
  function name() {
    if (peek()?.type !== "name") throw new SyntaxError(`Expected a name but found ${found()}`);
    return next().value;
  }
  function list(item) {
    expect("(");
    const items = [];
    while (!isNext(")")) {
      items.push(item());
      if (!isNext(")")) expect(",");
    }
    expect(")");
    return items;
  }
  function block() {
    expect("{");
    const body = [];
    while (!isNext("}")) body.push(statement());
    expect("}");
    return body;
  }

  function statement() {
    if (isNext("let")) {
      next();
      const target = name();
      expect("=");
      const value = expression();
      expect(";");
      return { type: "Let", name: target, value };
    }
    if (isNext("function")) {
      next();
      const target = name();
      const params = list(name);
      functionDepth += 1;
      const body = block();
      functionDepth -= 1;
      return { type: "Function", name: target, params, body };
    }
    if (isNext("return")) {
      if (functionDepth === 0) throw new SyntaxError("return is only allowed inside a function");
      next();
      const value = expression();
      expect(";");
      return { type: "Return", value };
    }
    if (isNext("if")) {
      next();
      expect("(");
      const test = expression();
      expect(")");
      const then = block();
      const otherwise = isNext("else") ? (next(), block()) : null;
      return { type: "If", test, then, otherwise };
    }
    const value = expression();
    expect(";");
    return { type: "Expression", value };
  }

  function expression() {
    const left = binary(0);
    if (isNext("=") && left.type === "Name") {
      next();
      return { type: "Assign", name: left.name, value: expression() };
    }
    return left;
  }
  const levels = [["===", "!==", "<", ">", "<=", ">="], ["+", "-"], ["*", "/"]];
  function binary(level) {
    if (level === levels.length) return call();
    let left = binary(level + 1);
    while (peek() && peek().type === "punct" && levels[level].includes(peek().value)) {
      const operator = next().value;
      left = { type: "Binary", operator, left, right: binary(level + 1) };
    }
    return left;
  }
  function call() {
    let callee = primary();
    while (isNext("(")) callee = { type: "Call", callee, args: list(expression) };
    return callee;
  }
  function primary() {
    const token = next();
    if (!token) throw new SyntaxError("Unexpected end of input");
    if (token.type === "number" || token.type === "string") return { type: "Literal", value: token.value };
    if (token.type === "name") return { type: "Name", name: token.value };
    if (token.value === "(") {
      const value = expression();
      expect(")");
      return value;
    }
    throw new SyntaxError(`Unexpected "${token.value}"`);
  }

  const body = [];
  while (index < tokens.length) body.push(statement());
  return { type: "Program", body };
}

Lines 7 to 10 look at the tokens: peek reads the next one, next takes it, and isNext checks it. Lines 11 to 18 take a token that must be there, such as ; or a name, and throw a SyntaxError if it isn't.

Lines 37 to 74 are statement. It looks at the first token to pick the kind of statement: let, function, return, if, or an expression followed by ;.

Lines 84 to 93 handle operators. Line 84 lists them in levels, loosest first. Each level asks the next, tighter level for its operands on lines 87 and 90. The while loop on line 88 puts each new operator above the one before.

Print the treesPop out in the code editor (opens in a new tab)JavaScript
import { parse } from "./parser.js";

function show(node) {
  if (node.type === "Binary") return "(" + node.operator + " " + show(node.left) + " " + show(node.right) + ")";
  if (node.type === "Call") return "(call " + show(node.callee) + " " + node.args.map(show).join(" ") + ")";
  if (node.type === "Name") return node.name;
  return String(node.value);
}

const program = parse("print(1 + 2 * 3);\nlet total = (1 + 2) * 3;\nlet left = 10 - 3 - 2;");
for (const statement of program.body) console.log(statement.type, show(statement.value));

Lines 3 to 8 print a node as a short list: the operator first, then its operands. Line 11 prints one line per statement: Expression (call print (+ 1 (* 2 3))), Let (* (+ 1 2) 3) and Let (- (- 10 3) 2).

Left: the tree for 1 + 2 * 3. The plus node is at the top, with 1 on its left and a times node on its right; the times node has 2 and 3. Times gives 6, plus gives 7. Right: the parser's five levels, from binary(0) for comparisons, the loosest, down to primary(), the tightest.+= 71*= 6231 + 2 * 3binary(0): === < >loosestbinary(1): + -binary(2): * /call()primary()tightest
Left: * sits lower in the tree, so the evaluator works it out first: 6, then 7. Right: the parser's levels. Each one asks the next, tighter level for its operands, which is why 2 * 3 becomes one operand of +.

So the parser, not the evaluator, decides precedence: * ends up lower in the tree, and lower nodes are worked out first. In 10 - 3 - 2, the first - is lower, so it is (10 - 3) - 2. The parsing and ASTs lesson shows the full trees real parsers build.

Step 3: walk the tree

The evaluator walks the tree, node by node, and works out values. That makes this a tree-walking interpreter. It also keeps track of variables, in scopes.

evaluator.jsJavaScript
import { parse } from "./parser.js";

class Scope {
  constructor(parent = null) {
    this.vars = new Map();
    this.parent = parent;
  }
  declare(name, value) {
    this.vars.set(name, value);
  }
  find(name) {
    for (let scope = this; scope; scope = scope.parent) if (scope.vars.has(name)) return scope;
    throw new ReferenceError(`${name} is not defined`);
  }
}

class ReturnSignal {
  constructor(value) {
    this.value = value;
  }
}

export function run(source, print = console.log) {
  const globals = new Scope();
  globals.declare("print", print);
  execBlock(parse(source).body, globals);
}

function execBlock(statements, scope) {
  for (const statement of statements) exec(statement, scope);
}

function exec(node, scope) {
  if (node.type === "Let") scope.declare(node.name, evaluate(node.value, scope));
  else if (node.type === "Function") scope.declare(node.name, makeFunction(node, scope));
  else if (node.type === "Return") throw new ReturnSignal(evaluate(node.value, scope));
  else if (node.type === "If") {
    if (evaluate(node.test, scope)) execBlock(node.then, new Scope(scope));
    else if (node.otherwise) execBlock(node.otherwise, new Scope(scope));
  } else evaluate(node.value, scope);
}

function makeFunction(node, closure) {
  return (...args) => {
    const scope = new Scope(closure);
    node.params.forEach((param, i) => scope.declare(param, args[i]));
    try {
      execBlock(node.body, scope);
    } catch (signal) {
      if (signal instanceof ReturnSignal) return signal.value;
      throw signal;
    }
    return undefined;
  };
}

function evaluate(node, scope) {
  if (node.type === "Literal") return node.value;
  if (node.type === "Name") return scope.find(node.name).vars.get(node.name);
  if (node.type === "Assign") {
    const value = evaluate(node.value, scope);
    scope.find(node.name).vars.set(node.name, value);
    return value;
  }
  if (node.type === "Call") {
    const fn = evaluate(node.callee, scope);
    if (typeof fn !== "function") throw new TypeError(`${fn} is not a function`);
    return fn(...node.args.map((arg) => evaluate(arg, scope)));
  }
  const left = evaluate(node.left, scope);
  const right = evaluate(node.right, scope);
  const operations = {
    "+": () => left + right, "-": () => left - right, "*": () => left * right, "/": () => left / right,
    "<": () => left < right, ">": () => left > right, "<=": () => left <= right, ">=": () => left >= right,
    "===": () => left === right, "!==": () => left !== right,
  };
  return operations[node.operator]();
}

Lines 3 to 15 are a Scope: a Map of names and values, plus a parent scope. Lines 23 to 27 are run: it makes the global scope, puts print in it, parses the text and runs each statement.

Lines 33 to 41 run statements. Line 34 stores a let, line 35 stores a function, lines 37 to 39 run one block or the other, and line 40 works out an expression. Lines 57 to 77 work out expressions: a literal is its own value (line 58), a name is looked up (line 59), and an operator works out both sides, then applies itself (lines 70 to 77).

return can be inside several blocks, so line 36 throws a ReturnSignal. The throw leaves every block at once, and the function made on lines 43 to 55 catches it on line 50 and returns its value.

Which part stops?Pop out in the code editor (opens in a new tab)JavaScript
import { run } from "./evaluator.js";

for (const program of ["let a = 5 # 2;", "let = 5;", "print(price);", "print(1 + 2)"]) {
  try {
    run(program);
  } catch (error) {
    console.log(error.name + ": " + error.message);
  }
}

Line 3 lists four broken programs, line 5 runs each one, and line 7 prints its error:

  • SyntaxError: Unexpected "#" at 10: the tokenizer has no rule for #.
  • SyntaxError: Expected a name but found "=": the parser needs a name after let.
  • ReferenceError: price is not defined: the evaluator finds no price in any scope.
  • SyntaxError: Expected ";" but found the end: the parser needs a ;.

Read each row of the table as: "this part takes this, gives back this, and stops on this."

The three parts of the interpreter
PartTakesGives backStops on
TokenizerTextA flat list of tokensA character it has no rule for: #
ParserTokensA tree of nodesTokens in the wrong order: a missing ;
EvaluatorA treeValues, and what print showsA name no scope has: price is not defined

Scopes and closures

Every function call gets a new scope. The question is which scope becomes its parent. In TinyJS, as in JavaScript, it is the scope where the function was written. That scope is the function's closure.

A counter in TinyJSPop out in the code editor (opens in a new tab)JavaScript
import { run } from "./evaluator.js";

run(`
function makeCounter() {
  let count = 0;
  function add() {
    count = count + 1;
    return count;
  }
  return add;
}
let counter = makeCounter();
print(counter());
print(counter());
let other = makeCounter();
print(other());
`);

Lines 4 to 11 define makeCounter, which returns add. Lines 13 and 14 print 1 and 2; line 16 prints 1. In evaluator.js, line 45 makes each call's scope with the closure as its parent.

Step through the counter in TinyJS
Step 0 of 14Ready
Your turn: follow the blue line

A replay of what the lesson's evaluator does for this program. The printed values come from running it.

Running in
  1. script
Next: line 3
import { run } from "./evaluator.js"; function makeCounter() {  let count = 0;  function add() {    count = count + 1;    return count;  }  return add;}let counter = makeCounter();print(counter());print(counter());let other = makeCounter();print(other());`);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Press Step to follow the program. This replays what evaluator.js does; the printed values come from running it.

At the top, the global scope holds print, makeCounter, counter and other. Below it, two scopes, one from each makeCounter call: the first has count 2, the second has count 1. Both point up to the global scope as their parent. At the bottom, the scope of a call to counter is empty and points up to the first scope.global scopeprint, makeCounter, counter, otherparentparentcount: 21st makeCounter() callcount: 12nd makeCounter() callparent(empty)a counter() callcount = count + 1finds count one level up
After the program runs: each makeCounter call made its own scope with its own count. A call to counter starts in an empty scope; find follows the parent arrows until it reaches a scope that has the name.
Where show was writtenPop out in the code editor (opens in a new tab)JavaScript
import { run } from "./evaluator.js";

run(`
let x = "outer";
function show() {
  return x;
}
function test() {
  let x = "inner";
  return show();
}
print(test());
`);

Line 12 calls test, which has its own x on line 9 and calls show. But show was written at the top level, so it finds the top-level x, and line 12 prints outer. This is lexical scope. The closures lesson shows the same in JavaScript.

Where real engines differ

V8, SpiderMonkey and JavaScriptCore start the same way: tokens, then a tree. After that, they do much more. Read each row of the table as: "for this job, TinyJS does this, and a real engine does this."

TinyJS next to a real engine
JobTinyJSA real engine, such as V8
TokensOne regular expression; ASCII names only.A hand-written scanner for all of Unicode, every number format, templates and regular expressions.
TreeThe whole program, before it runs.Pre-parses most function bodies quickly, and builds their full tree when they are first called.
RunningWalks the tree, every time.Turns the tree into bytecode; compiles hot functions to machine code.
VariablesA Map per scope, searched by name on every read.Works out where most variables live before running, then reads them by position.
ErrorsA message, with a position only from the tokenizer.A line and column for every syntax error, and a stack trace for errors while running.

The biggest difference is how the tree is run. Walking a tree means jumping from node to node for every operation. Real engines turn the tree into bytecode, a flat list of small instructions, and run that. Functions that run often are compiled to machine code while the program runs, by a JIT (just-in-time) compiler.

Two rows. TinyJS: text, tokens, tree, then walk the tree each time. V8: text, tokens, tree, then bytecode run by the Ignition interpreter, and for hot functions, machine code from Sparkplug, Maglev and TurboFan.TinyJS (this lesson)text → tokens → treewalk the treeevery run, every callV8 (Chrome, Node.js)text → tokens → treebytecode: Ignitionmachine code, for hot codeSparkplug, Maglev, TurboFan
Both start the same way: text, tokens, a tree. TinyJS walks the tree every time it runs. Real engines turn the tree into bytecode, a flat list of small instructions, and compile functions that run often into machine code.

The bytecode and interpreters lesson shows real bytecode, and the engines overview names each engine's tiers.

Run your own TinyJS

Write a TinyJS program, and see its tree, its tokens, and what it prints. The playground runs this lesson's three files in this page.

Playground: run your own TinyJS
The tree
  1. (function square (n) (return (* n n)))
  2. (let side 4)
  3. (call print "area" (call square side))
What print printedrunning

Running…

Try it yourself
Running…

Your program is running in a worker, so a slow program can't freeze this page.

The tokenizer, parser and evaluator are this lesson's code, running in this page. Programs run in a worker that is stopped after 1 second.

Pick Too slow: slow(40) would make over a trillion calls, so the playground stops it after 1 second. Pick An error, then fix the name on line 2.

Ideas to try next, each one touching all three parts:

  • Add %: a token rule, a place in a parser level, and an entry in operations.
  • Add while: a keyword, a branch in statement, and a loop in exec.
  • Keep each token's position, so every error can say where it happened.
Which part does this job?
  • Splitting let total into two tokens
  • Throwing Unexpected "#"
  • Putting 2 * 3 under +
  • Throwing Expected ";"
  • Adding 2 and 3
  • Finding count in a parent scope
  • Turning the tree into bytecode
  • Compiling a hot function to machine code
Try it yourself
0 of 8 correct

Put each job with the part that does it: the tokenizer, the parser, the evaluator, or only real engines.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

Most mix-ups come from mixing up what each part does, or where a function looks for its variables.

  • "An interpreter runs the text directly." No. It runs a tree, or bytecode, made from the text.
  • "The evaluator decides that * goes first." No. The parser does, by putting * lower in the tree.
  • "A function looks up names where it is called." No. It looks where it was written: in its closure.
  • "An engine is either an interpreter or a compiler." No. Real engines interpret bytecode first, then compile hot functions.
  • "The tokenizer catches every syntax error." No. It only knows single tokens. A missing ; is found by the parser.

The table below puts three of these side by side. Read each row as: "people often think this, but this is true."

What is true about interpreters
TopicWhat people thinkWhat is true
InterpretersAn interpreter runs the text directly.It runs a tree, or bytecode, made from the text.
PrecedenceThe evaluator decides that * goes first.The parser does: * ends up lower in the tree.
ClosuresA function finds variables where it is called.It finds them where it was written: in its closure.

Practice exercises

Each exercise is a small piece of one part, on its own. Guess first, then run it.

Exercise 1 · Warm-upCount the tokens

What does line 16 print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const pattern = /(\d+)|([A-Za-z_]\w*)|([-+*/=;()])/y;
function tokenize(source) {
  const tokens = [];
  let position = 0;
  while (position < source.length) {
    if (source[position] === " ") {
      position += 1;
      continue;
    }
    pattern.lastIndex = position;
    tokens.push(pattern.exec(source)[0]);
    position = pattern.lastIndex;
  }
  return tokens;
}
console.log(tokenize("let area = (w + 2) * h;").length);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upLeft side first

    What does line 13 print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const tree = {
      type: "Binary",
      operator: "-",
      left: { type: "Binary", operator: "-", left: { type: "Literal", value: 10 }, right: { type: "Literal", value: 3 } },
      right: { type: "Literal", value: 2 },
    };
    function evaluate(node) {
      if (node.type === "Literal") return node.value;
      const left = evaluate(node.left);
      const right = evaluate(node.right);
      return node.operator === "-" ? left - right : left + right;
    }
    console.log(evaluate(tree));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAdd the % operator

      This throws. Add % to the evaluator, so 10 % 3 works. What does your program print?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      function evaluate(node) {
        if (node.type === "Literal") return node.value;
        const left = evaluate(node.left);
        const right = evaluate(node.right);
        const operations = {
          "+": () => left + right,
          "*": () => left * right,
        };
        return operations[node.operator]();
      }
      const tree = { type: "Binary", operator: "%", left: { type: "Literal", value: 10 }, right: { type: "Literal", value: 3 } };
      console.log(evaluate(tree));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeLook in the parent scope

        This prints 10 not found. Change line 8 so get looks in the parent scope. What does your program print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        class Scope {
          constructor(parent = null) {
            this.vars = new Map();
            this.parent = parent;
          }
          get(name) {
            if (this.vars.has(name)) return this.vars.get(name);
            return "not found";
          }
        }
        const globals = new Scope();
        globals.vars.set("tax", 0.2);
        const inner = new Scope(globals);
        inner.vars.set("price", 10);
        console.log(inner.get("price"), inner.get("tax"));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeUse the scope it was written in

          This prints inner. Change line 15 so show finds the x from where it was written. What does your program print?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          class Scope {
            constructor(parent = null) {
              this.vars = new Map();
              this.parent = parent;
            }
            get(name) {
              return this.vars.has(name) ? this.vars.get(name) : this.parent.get(name);
            }
          }
          const globals = new Scope();
          globals.vars.set("x", "outer");
          // show was written at the top level, so it should see the top-level scope.
          const show = { definedIn: globals, body: (scope) => scope.get("x") };
          function call(fn, callerScope) {
            return fn.body(new Scope(callerScope));
          }
          const testScope = new Scope(globals);
          testScope.vars.set("x", "inner");
          console.log(call(show, testScope));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeLongest match first

            This prints a | = | = | = | 1. Change line 1 so === is one token. What does your program print?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            const pattern = /([A-Za-z_]\w*)|(\d+)|(=|===|!==)/y;
            function tokenize(source) {
              const tokens = [];
              let position = 0;
              while (position < source.length) {
                if (source[position] === " ") {
                  position += 1;
                  continue;
                }
                pattern.lastIndex = position;
                tokens.push(pattern.exec(source)[0]);
                position = pattern.lastIndex;
              }
              return tokens;
            }
            console.log(tokenize("a === 1").join(" | "));

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which part does the work: the tokenizer, the parser or the evaluator. Each answer explains why it's right or wrong.

              The interpreter quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the tokenizer give the parser?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this tree evaluate to?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const tree = { op: "*", left: { op: "+", left: 1, right: 2 }, right: 4 };
                const value = (node) => (typeof node === "number" ? node : node.op === "+" ? value(node.left) + value(node.right) : value(node.left) * value(node.right));
                console.log(value(tree));

                Choose an answer to see the explanation.

              3. Question 3 of 7In this lesson's parser, why does 2 * 3 end up as one operand of + in 1 + 2 * 3?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                let x = 1;
                function show() {
                  return x;
                }
                function test() {
                  let x = 2;
                  return show();
                }
                console.log(test());

                Choose an answer to see the explanation.

              5. Question 5 of 7How does return in TinyJS leave a function from inside an if block?

                Choose an answer to see the explanation.

              6. Question 6 of 7Why does each call to makeCounter get its own count?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does V8 do that TinyJS doesn't?

                Choose an answer to see the explanation.

              Key takeaways

              Every JavaScript engine starts the way TinyJS does; the rest is making it fast and complete.

              • The tokenizer turns text into tokens; it only knows single words and symbols.
              • The parser turns tokens into a tree, and its levels decide precedence.
              • The evaluator walks the tree; return can be a throw that the call catches.
              • Each call gets a new scope whose parent is the function's closure: where it was written.
              • Real engines make bytecode from the tree, and compile hot functions to machine code.

              Remember the one-liner.
              Split the text, build the tree, walk the tree, and look up names where the function was written.

              This is the last lesson of the JavaScript course. Go back to the course overview to revisit any lesson, or keep building TinyJS.

              CompleteFrontend Clear concepts. Working examples.