InterviewsVector
advancedRare7 min read · Updated Jul 18, 2026

Implement JSON.parse from Scratch: Recursive-Descent Parsing

Implement JSON.parse in JavaScript with a recursive-descent parser: the grammar-to-function mapping, string escapes including \uXXXX, strict number rules, and error positions.


The problem

Implement parse(json) that turns a JSON string into a JavaScript value — objects, arrays, strings (with escapes), numbers (with exponents), true/false/null — and throws SyntaxError on malformed input.

This is the deepest question in the polyfill family, and the one where interviewers are explicitly testing architecture, not recall: can you take a grammar and turn it into code with a shape that mirrors it? The technique — recursive descent — is the same one real compilers front-ends use, scaled down to a grammar small enough for 40 minutes.

If you've done the string tokenizer, this is that plus a grammar on top; if an interviewer offers a choice, doing JSON.stringify first makes this one easier to narrate.

The insight: the grammar IS the code

JSON's entire grammar fits on an index card, and each production becomes exactly one function:

value  → object | array | string | number | "true" | "false" | "null"
object → "{" ( string ":" value ( "," string ":" value )* )? "}"
array  → "[" ( value ( "," value )* )? "]"

parseValue dispatches on the first character; parseObject and parseArray call back into parseValue — that mutual recursion is what handles arbitrary nesting for free. Say this mapping out loud before coding; it's the difference between "writing a parser" and "fighting a string."

Implementation

function parse(json) {
  let i = 0; // cursor — shared by all parse functions via closure
 
  function error(msg) {
    throw new SyntaxError(`${msg} at position ${i}`);
  }
 
  function skipWhitespace() {
    while (" \n\t\r".includes(json[i])) i++;
  }
 
  // ---- value dispatch: one look-ahead character decides everything ----
  function parseValue() {
    skipWhitespace();
    const ch = json[i];
    if (ch === '"') return parseString();
    if (ch === "{") return parseObject();
    if (ch === "[") return parseArray();
    if (ch === "t") return parseLiteral("true", true);
    if (ch === "f") return parseLiteral("false", false);
    if (ch === "n") return parseLiteral("null", null);
    if (ch === "-" || (ch >= "0" && ch <= "9")) return parseNumber();
    error(ch === undefined ? "Unexpected end of input" : `Unexpected token '${ch}'`);
  }
 
  function parseLiteral(word, value) {
    if (json.slice(i, i + word.length) !== word) error(`Unexpected token`);
    i += word.length;
    return value;
  }
 
  function parseString() {
    i++; // consume opening quote
    let result = "";
    while (i < json.length && json[i] !== '"') {
      if (json[i] === "\\") {
        i++;
        const esc = json[i];
        if (esc === "u") {
          // \uXXXX — the escape most hand-rolled parsers forget
          const hex = json.slice(i + 1, i + 5);
          if (!/^[0-9a-fA-F]{4}$/.test(hex)) error("Invalid \\u escape");
          result += String.fromCharCode(parseInt(hex, 16));
          i += 5;
        } else {
          const map = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
          if (!(esc in map)) error(`Invalid escape '\\${esc}'`);
          result += map[esc];
          i++;
        }
      } else {
        result += json[i++];
      }
    }
    if (json[i] !== '"') error("Unterminated string");
    i++; // consume closing quote
    return result;
  }
 
  function parseNumber() {
    const start = i;
    if (json[i] === "-") i++;
    while (json[i] >= "0" && json[i] <= "9") i++;
    if (json[i] === ".") {
      i++;
      while (json[i] >= "0" && json[i] <= "9") i++;
    }
    if (json[i] === "e" || json[i] === "E") {
      i++;
      if (json[i] === "+" || json[i] === "-") i++;
      while (json[i] >= "0" && json[i] <= "9") i++;
    }
    const num = Number(json.slice(start, i));
    if (Number.isNaN(num)) error("Invalid number");
    return num;
  }
 
  function parseObject() {
    const result = {};
    i++; // consume {
    skipWhitespace();
    if (json[i] === "}") { i++; return result; }
 
    while (true) {
      skipWhitespace();
      if (json[i] !== '"') error("Expected string key"); // keys MUST be strings
      const key = parseString();
      skipWhitespace();
      if (json[i] !== ":") error("Expected ':' after key");
      i++;
      result[key] = parseValue();
      skipWhitespace();
      if (json[i] === "}") { i++; return result; }
      if (json[i] !== ",") error("Expected ',' or '}'");
      i++;
    }
  }
 
  function parseArray() {
    const result = [];
    i++; // consume [
    skipWhitespace();
    if (json[i] === "]") { i++; return result; }
 
    while (true) {
      result.push(parseValue());
      skipWhitespace();
      if (json[i] === "]") { i++; return result; }
      if (json[i] !== ",") error("Expected ',' or ']'");
      i++;
    }
  }
 
  // ---- top level: exactly ONE value, then end of input ----
  const value = parseValue();
  skipWhitespace();
  if (i < json.length) error(`Unexpected token '${json[i]}'`);
  return value;
}

Verified behavior

parse('{"name":"John \\"JD\\"","scores":[1,2.5,-3e2],"ok":true,"x":null}');
// { name: 'John "JD"', scores: [1, 2.5, -300], ok: true, x: null }
 
parse('"snow: \\u2603"');   // 'snow: ☃' — \uXXXX decoded
parse("  42  ");            // 42 — surrounding whitespace fine
parse("1 2");               // SyntaxError: Unexpected token '2' at position 2
parse('{"a":1,}');          // SyntaxError — JSON forbids trailing commas
parse("{'a':1}");           // SyntaxError — single quotes aren't JSON
parse("");                  // SyntaxError: Unexpected end of input

The check everyone forgets: trailing garbage

A parser that returns after the first value silently accepts '1 2', '{} []', or 'null garbage'. Native JSON.parse throws — because the grammar says a document is one value. That final skipWhitespace(); if (i < json.length) error(...) is two lines, and its absence is the most common bug interviewers plant. It matters in production too: truncated-or-concatenated payloads should fail loudly, not half-parse.

Edge cases interviewers probe

  • \uXXXX escapes — required by the grammar; without them "café" (exactly what servers emit for non-ASCII) throws in your parser.
  • Keys must be quoted strings{a:1} and {'a':1} are JavaScript, not JSON. Your parseObject must check for " before calling parseString, or it silently mis-parses.
  • Trailing commas[1,2,] is invalid JSON. The if (json[i] !== ",")-then-loop structure rejects it naturally; split-brained approaches don't.
  • JSON that's valid but weird — top-level scalars ("hi", 42, null) are legal documents; your top level must not assume an object.
  • Strict number syntax you may choose to skip — real JSON also rejects leading zeros (01), 1. and .5. Handling them fully is grammar bookkeeping; naming them and stating you'd add digit-count checks is usually accepted at interview pace.
  • Deep nesting — recursion depth equals nesting depth; "[".repeat(1e5) will blow the call stack. Native parsers have the same practical limit; the fix (an explicit stack) is a great "how would you productionize" answer.

Common mistakes

  • No end-of-input check (accepts '1 2').
  • Escape handling after the quote check — "a\"" terminates early. Escapes must be consumed first (same ordering lesson as the tokenizer).
  • Calling parseString for keys without verifying the " is there.
  • Number(...) on an empty or junk slice returning NaN and flowing silently into the result — guard it.
  • Errors without positions. Expected ':' after key at position 27 turns a rejected answer into an accepted one; it costs one template literal.
  • Rebuilding the parser as a pile of indexOf/split calls. The question is specifically screening for whether you know the recursive technique.

Follow-up questions

  • "Add the reviver parameter." — post-order walk: after parsing, visit each (key, value) bottom-up and replace values, exactly mirroring stringify's replacer. Good 10-minute extension.
  • "Why recursive descent and not regex?" — JSON is a context-free grammar with arbitrary nesting; regular expressions can't match nested brackets. One sentence, full marks.
  • "What's the complexity?" — O(n) time (the cursor only advances), O(depth) stack.
  • "How does this relate to real parsers?" — same architecture as hand-written front-ends (V8's JSON parser is a hand-rolled recursive descent in C++); production adds a separate tokenizer stage, error recovery, and streaming.
  • "Why is JSON.parse faster than JavaScript object literals in some benchmarks?" — JSON's grammar is radically simpler than JS's, so the parser does less look-ahead; this is why "put big config in JSON.parse('...')" was a real optimization advice from the V8 team.
  • "Make it non-recursive." — explicit stack of partially-built containers; the follow-up that separates "knows the trick" from "owns the technique."

  • Implement reduce including the no-initial-value case and empty-array TypeError.

  • Serialize values by hand: undefined, functions, cycles, and all the special cases JSON defines.

  • Strip whitespace without regex catastrophes — and enumerate what counts as whitespace.

  • Reimplement typeof with Object.prototype.toString and learn why typeof null === 'object'.