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.
- 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
evalornew Function. JSON whitespace is four characters. JS is more lenient, and it executes.- The lexer consumes strings, numbers, literals, and punctuation.
parseValuedispatches; 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.reviveris 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
- Exact engine
SyntaxErrortext. JSON.rawJSONand a streaming parser.- Native ToString of the argument (
JSON.parse(1) === 1). This sketch throws on non-strings. - Unpaired surrogates and a claim of full RFC 8259 completeness.
Procedure
- Reject non-strings. Keep an index. Skip space, tab, LF, CR only.
- Dispatch: brace is object, bracket is array, quote is string, t/f/n are literals, else a number.
- In object or array, a comma must be followed by another element, never by a closer.
- After the root value, skip whitespace. Any leftover character is
SyntaxError. - If
reviveris a function, post-order walk from an empty-string root key.undefinedmeans 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 on this site
- Implement JSON.stringify
- JSON parse and stringify pitfalls
- Error types and custom errors
- Polyfill questions playbook
- Form serialize to JSON
Related guides
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.