ESC

Type to search the knowledge base.

JSON.parse Implementation

Parse JSON with recursive descent, never eval. Why trailing commas throw, how strings and numbers tokenize, and where a reviver walks after.

advanced7 min read
  • javascript
  • json
  • interview
  • parsing

The usual JSON.parse implementation is eval('(' + text + ')') or new Function('return ' + text)(). Both accept trailing commas, comments, unquoted keys, and undefined. Both will also run a payload that looks like JSON until the first function body. JSON is a grammar. JavaScript is not that grammar.

Native JSON.parse throws SyntaxError on leftover input, on NaN, on '[1,]', and on '{"a":1,}'. It never produces undefined. A reviver, if you pass one, runs after the tree exists, as a post-order walk, not during tokenize.

This page is an interview-sized parser: tokenize helpers plus recursive descent for object, array, string, number, true, false, null. It is not a claim of full RFC 8259 or full ECMA-262 coverage. Name the gaps.

Key takeaways

  • Do not eval or new Function. JSON whitespace is four characters. JS is more lenient, and it executes.
  • The lexer consumes strings, numbers, literals, and punctuation. parseValue dispatches; object and array recurse.
  • A comma then } or ] is a trailing comma: throw. Comments are not tokens.
  • undefined, NaN, Infinity, +1, 01, and single-quoted strings are all syntax errors.
  • reviver is optional and runs after parse, on a wrapper whose key is the empty string.

A recursive descent, not eval, and why trailing commas throw

JSON text is a single value, optional surrounding whitespace, then end of input. A value is an object, an array, a string, a number, or the literals true, false, null.

The tokenizer is not a separate package. It is the set of helpers that advance an index: skip the four whitespace characters (space, tab, LF, CR), read a quoted string, read a number token, match a literal word, or take one punctuation character. Vertical tab and non-breaking space are not JSON whitespace. eval would accept them.

Token Source shape Notes
string "..." with \", \\, \/, \b, \f, \n, \r, \t, \uXXXX Unescaped controls throw. Single quotes throw.
number optional -, integer, optional fraction, optional exponent No leading zeros. No +. No NaN / Infinity.
literal true false null undefined is not a token.
punctuation { } [ ] : , Comma is a separator, never a trailer.
function jsonParse(text, reviver) {
  if (typeof text !== 'string') {
    throw new TypeError('jsonParse expects a string');
  }

  let i = 0;
  const n = text.length;
  const fail = (msg) => { throw new SyntaxError(msg + ' at ' + i); };
  const peek = () => text[i];
  const isDigit = (c) => c >= '0' && c <= '9';
  const skipWs = () => { while (i < n && ' \n\r\t'.includes(text[i])) i += 1; };

  function parseLiteral(word, value) {
    if (text.slice(i, i + word.length) !== word) fail('expected ' + word);
    i += word.length;
    return value;
  }

  function parseString() {
    i += 1;
    let out = '';
    while (i < n) {
      const c = text[i++];
      if (c === '"') return out;
      if (c === '\\') {
        const e = text[i++];
        const table = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
        if (table[e]) { out += table[e]; continue; }
        if (e === 'u') {
          const hex = text.slice(i, i + 4);
          if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape');
          out += String.fromCharCode(parseInt(hex, 16));
          i += 4;
          continue;
        }
        fail('bad escape');
      }
      if (c.charCodeAt(0) < 0x20) fail('unescaped control');
      out += c;
    }
    fail('unterminated string');
  }

  function parseNumber() {
    const start = i;
    if (peek() === '-') i += 1;
    if (peek() === '0') {
      i += 1;
      if (isDigit(peek())) fail('leading zero');
    } else {
      if (!isDigit(peek())) fail('bad number');
      while (isDigit(peek())) i += 1;
    }
    if (peek() === '.') {
      i += 1;
      if (!isDigit(peek())) fail('bad fraction');
      while (isDigit(peek())) i += 1;
    }
    if (peek() === 'e' || peek() === 'E') {
      i += 1;
      if (peek() === '+' || peek() === '-') i += 1;
      if (!isDigit(peek())) fail('bad exponent');
      while (isDigit(peek())) i += 1;
    }
    return Number(text.slice(start, i));
  }

  function parseCollection(closeChar, onItem) {
    i += 1;
    skipWs();
    if (peek() === closeChar) { i += 1; return; }
    while (true) {
      onItem();
      skipWs();
      if (peek() === ',') {
        i += 1;
        skipWs();
        if (peek() === closeChar) fail('trailing comma');
        continue;
      }
      if (peek() === closeChar) { i += 1; return; }
      fail('expected comma or ' + closeChar);
    }
  }

  function parseValue() {
    skipWs();
    if (i >= n) fail('unexpected end');
    const c = peek();
    if (c === '{') {
      const obj = {};
      parseCollection('}', () => {
        skipWs();
        if (peek() !== '"') fail('expected string key');
        const key = parseString();
        skipWs();
        if (peek() !== ':') fail('expected colon');
        i += 1;
        Object.defineProperty(obj, key, {
          value: parseValue(),
          writable: true,
          enumerable: true,
          configurable: true,
        });
      });
      return obj;
    }
    if (c === '[') {
      const arr = [];
      parseCollection(']', () => { arr.push(parseValue()); });
      return arr;
    }
    if (c === '"') return parseString();
    if (c === 't') return parseLiteral('true', true);
    if (c === 'f') return parseLiteral('false', false);
    if (c === 'n') return parseLiteral('null', null);
    if (c === '-' || isDigit(c)) return parseNumber();
    fail('unexpected ' + c);
  }

  const value = parseValue();
  skipWs();
  if (i < n) fail('unexpected trailing input');

  if (typeof reviver !== 'function') return value;

  function walk(holder, key) {
    const current = holder[key];
    if (current !== null && typeof current === 'object') {
      if (Array.isArray(current)) {
        for (let k = 0; k < current.length; k++) {
          const next = walk(current, String(k));
          if (next === undefined) delete current[k];
          else current[k] = next;
        }
      } else {
        for (const k of Object.keys(current)) {
          const next = walk(current, k);
          if (next === undefined) delete current[k];
          else current[k] = next;
        }
      }
    }
    return reviver.call(holder, key, current);
  }

  return walk({ '': value }, '');
}
jsonParse('{"ok":true,"n":1}'); // { ok: true, n: 1 }
jsonParse('[1,]'); // SyntaxError trailing comma
jsonParse('undefined'); // SyntaxError

JavaScript object literals allow a trailing comma. JSON does not. After a comma, the next non-space character cannot be } or ].

Numbers and strings

Do not parseFloat a prefix. 01, .1, 1., and +1 are illegal. Number(slice) matches engines, so 1e400 becomes Infinity. JSON.stringify(Infinity) is 'null'; it does not round-trip.

Strings reject unescaped controls U+0000 to U+001F. \u must be four hex digits.

Reviver is a post-walk

Parse first. Then, if reviver is a function, wrap the root in an object whose empty-string key holds the value, and walk. Each node is revived after its children. The call is reviver.call(holder, key, value). Returning undefined deletes that property (delete on an array index leaves a hole). The root call uses the empty string as the key. You do not run reviver during tokenize: there is no object to mutate yet.

jsonParse('{"keep":1,"drop":2}', (key, value) => (key === 'drop' ? undefined : value));
// { keep: 1 }

Assignment (obj[key] =) would run the inherited __proto__ setter and change [[Prototype]]. Native JSON.parse uses CreateDataPropertyOrThrow, so '{"__proto__":{"polluted":true}}' is an own data property and Object.getPrototypeOf stays Object.prototype. defineProperty is the interview stand-in.

What this subset omits

  1. Exact engine SyntaxError text.
  2. JSON.rawJSON and a streaming parser.
  3. Native ToString of the argument (JSON.parse(1) === 1). This sketch throws on non-strings.
  4. Unpaired surrogates and a claim of full RFC 8259 completeness.

Procedure

  1. Reject non-strings. Keep an index. Skip space, tab, LF, CR only.
  2. Dispatch: brace is object, bracket is array, quote is string, t/f/n are literals, else a number.
  3. In object or array, a comma must be followed by another element, never by a closer.
  4. After the root value, skip whitespace. Any leftover character is SyntaxError.
  5. If reviver is a function, post-order walk from an empty-string root key. undefined means delete.

Interview answer (out loud)

I would not eval. I keep an index, skip JSON whitespace, and dispatch on the next character: quote is a string, digit or minus is a number, brace and bracket recurse, and true/false/null are exact words. After a comma in an object or array I reject another closer. Leftover input after the root value throws. undefined and NaN are not in the grammar. If they pass a reviver, I walk the finished tree post-order from a wrapper whose key is the empty string, and delete when it returns undefined. This is an interview subset, not a full RFC claim.

Further reading

Related guides