Array.prototype.reduce
intermediateImplement reduce including the no-initial-value case and empty-array TypeError.
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.
Implement
parse(json)that turns a JSON string into a JavaScript value — objects, arrays, strings (with escapes), numbers (with exponents),true/false/null— and throwsSyntaxErroron 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.
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."
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;
}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 inputA 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.
\uXXXX escapes — required by the grammar; without them "café" (exactly what servers emit for non-ASCII) throws in your parser.{a:1} and {'a':1} are JavaScript, not JSON. Your parseObject must check for " before calling parseString, or it silently mis-parses.[1,2,] is invalid JSON. The if (json[i] !== ",")-then-loop structure rejects it naturally; split-brained approaches don't."hi", 42, null) are legal documents; your top level must not assume an object.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."[".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.'1 2')."a\"" terminates early. Escapes must be consumed first (same ordering lesson as the tokenizer).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.Expected ':' after key at position 27 turns a rejected answer into an accepted one; it costs one template literal.indexOf/split calls. The question is specifically screening for whether you know the recursive technique.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.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.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'.