JSON.parse turns a JSON string into the JavaScript value it describes. Under the hood it's a recursive-descent parser: a set of small functions, one per grammar rule, that read the text left to right and call each other to handle nesting. It's the counterpart to JSON.stringify, and building it is the classic way to learn how parsers work.
Implement jsonParse(text). Read the string with a single moving cursor and produce the corresponding value — objects, arrays, strings (with escapes), numbers, and the literals true/false/null — ignoring insignificant whitespace. Throw a SyntaxError on malformed input.
function jsonParse(text) {
// returns the parsed value; throws SyntaxError on invalid JSON.
}
jsonParse('{"a": 1, "b": [2, 3]}'); // { a: 1, b: [2, 3] }
jsonParse('[true, null, "hi"]'); // [true, null, 'hi']
jsonParse('-1.5e2'); // -150
jsonParse('"a\\nb"'); // 'a\nb' — escape decoded
jsonParse('{}x'); // throws SyntaxError (trailing characters)
true/false/null. Objects and arrays nest values recursively.\", \\, \/, \n, \t, \r, \b, \f, and \uXXXX.SyntaxError, not return a partial result.You'll build a small recursive-descent parser: one function per kind of JSON value, a single moving cursor, and functions that call each other to handle nesting.
A JSON string is text; you want the value it represents. That means parsing — reading the characters left to right and building the matching JavaScript object, array, string, or number. JSON's grammar is small and self-referential (an array holds values, which may themselves be arrays), which makes it a perfect fit for recursive descent: a function for each grammar rule, calling down into others when it hits nesting. You're rebuilding JSON.parse.
Two ideas carry the whole parser. First, one cursor: a single index i into the string that every function advances as it consumes characters, so parsing is just "look at text[i], decide what it starts, read it, move i past it." Second, dispatch by first character: a parseValue function peeks at the current character and delegates — { → object, [ → array, " → string, a digit or - → number, t/f/n → a literal.
The notorious shortcut is eval:
function jsonParseNaive(text) {
return eval('(' + text + ')');
}
It "works" for valid JSON, and that's exactly the trap. eval executes its input as JavaScript, so it happily runs jsonParseNaive('alert(1)') or jsonParseNaive('{a: 1+1}') — arbitrary code and non-JSON syntax that real JSON.parse rejects. It's a serious security hole on any untrusted input, and it doesn't enforce the JSON grammar. We need a parser that recognizes only JSON and throws on anything else.
function jsonParse(text) {
let i = 0; // the single cursor
const error = (msg) => {
throw new SyntaxError(`${msg} at position ${i}`);
};
const skipWs = () => {
while (i < text.length && ' \t\n\r'.includes(text[i])) i++;
};
function parseValue() {
skipWs();
const ch = text[i];
if (ch === '{') return parseObject();
if (ch === '[') return parseArray();
if (ch === '"') return parseString();
if (ch === '-' || (ch >= '0' && ch <= '9')) return parseNumber();
if (text.startsWith('true', i)) return (i += 4), true;
if (text.startsWith('false', i)) return (i += 5), false;
if (text.startsWith('null', i)) return (i += 4), null;
error('Unexpected token');
}
function parseObject() {
i++; // consume '{'
const obj = {};
skipWs();
if (text[i] === '}') return i++, obj; // empty
while (true) {
skipWs();
if (text[i] !== '"') error('Expected string key');
const key = parseString();
skipWs();
if (text[i] !== ':') error("Expected ':'");
i++;
obj[key] = parseValue(); // recurse
skipWs();
if (text[i] === ',') { i++; continue; }
if (text[i] === '}') return i++, obj;
error("Expected ',' or '}'");
}
}
function parseArray() {
i++; // consume '['
const arr = [];
skipWs();
if (text[i] === ']') return i++, arr; // empty
while (true) {
arr.push(parseValue()); // recurse
skipWs();
if (text[i] === ',') { i++; continue; }
if (text[i] === ']') return i++, arr;
error("Expected ',' or ']'");
}
}
function parseString() {
i++; // opening quote
let out = '';
const escapes = { '"': '"', '\\': '\\', '/': '/', n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' };
while (i < text.length) {
const ch = text[i++];
if (ch === '"') return out;
if (ch === '\\') {
const e = text[i++];
if (e === 'u') {
out += String.fromCharCode(parseInt(text.slice(i, i + 4), 16));
i += 4;
} else if (e in escapes) {
out += escapes[e];
} else error('Invalid escape');
} else {
out += ch;
}
}
error('Unterminated string');
}
function parseNumber() {
const start = i;
if (text[i] === '-') i++;
while (text[i] >= '0' && text[i] <= '9') i++;
if (text[i] === '.') { i++; while (text[i] >= '0' && text[i] <= '9') i++; }
if (text[i] === 'e' || text[i] === 'E') {
i++;
if (text[i] === '+' || text[i] === '-') i++;
while (text[i] >= '0' && text[i] <= '9') i++;
}
return Number(text.slice(start, i));
}
const result = parseValue();
skipWs();
if (i < text.length) error('Unexpected trailing characters');
return result;
}
module.exports = { jsonParse };
Each function owns one grammar rule and follows the same contract: assume i points at the start of its construct, consume exactly the characters it recognizes, and leave i just past them. parseValue is the dispatcher. parseObject and parseArray loop, calling parseValue for each nested value — that recursion is what lets arbitrary nesting parse with no special cases. parseString decodes escapes character by character; parseNumber scans the sign/fraction/exponent shape and hands the slice to Number. skipWs is sprinkled between tokens to ignore whitespace. Finally, after the top-level parseValue, we skip trailing whitespace and require the string to be fully consumed — that check is what makes '{}x' a syntax error instead of silently ignoring the x.
Take jsonParse('{"a": [1]}'):
parseValue — skipWs, sees { → parseObject.parseObject — consumes {; not empty; reads the key with parseString → 'a'; consumes :; calls parseValue for the value.
parseValue — sees [ → parseArray.
parseArray — consumes [; calls parseValue → parseNumber reads 1. After the element, sees ] → returns [1].obj.a = [1]. Back in parseObject: sees } → returns { a: [1] }.skipWs; i is at the end of the string, so no trailing-character error. Return { a: [1] }.The [1] parsed via a nested parseValue → parseArray → parseNumber call chain, three levels deep on the call stack — exactly mirroring the three levels of nesting in the text.
eval is not a parser — it executes arbitrary JavaScript and accepts non-JSON. Never use it on untrusted input; write a real parser that enforces the grammar.i just past what it consumed. An off-by-one here desynchronizes every function after it.i reached the end. Otherwise '1 2' or '{}x' parse the first token and silently ignore the rest.\n, escaped quotes, and \uXXXX. Decode escapes in parseString, and treat an unterminated string as an error.reviver argument — JSON.parse(text, reviver) walks the parsed result, letting you transform values (e.g. revive ISO date strings into Dates). It's a post-order traversal layered on top of the parser.onKey, onValue) as bytes arrive instead of building the whole tree in memory.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
JSON.parse turns a JSON string into the JavaScript value it describes. Under the hood it's a recursive-descent parser: a set of small functions, one per grammar rule, that read the text left to right and call each other to handle nesting. It's the counterpart to JSON.stringify, and building it is the classic way to learn how parsers work.
Implement jsonParse(text). Read the string with a single moving cursor and produce the corresponding value — objects, arrays, strings (with escapes), numbers, and the literals true/false/null — ignoring insignificant whitespace. Throw a SyntaxError on malformed input.
function jsonParse(text) {
// returns the parsed value; throws SyntaxError on invalid JSON.
}
jsonParse('{"a": 1, "b": [2, 3]}'); // { a: 1, b: [2, 3] }
jsonParse('[true, null, "hi"]'); // [true, null, 'hi']
jsonParse('-1.5e2'); // -150
jsonParse('"a\\nb"'); // 'a\nb' — escape decoded
jsonParse('{}x'); // throws SyntaxError (trailing characters)
true/false/null. Objects and arrays nest values recursively.\", \\, \/, \n, \t, \r, \b, \f, and \uXXXX.SyntaxError, not return a partial result.You'll build a small recursive-descent parser: one function per kind of JSON value, a single moving cursor, and functions that call each other to handle nesting.
A JSON string is text; you want the value it represents. That means parsing — reading the characters left to right and building the matching JavaScript object, array, string, or number. JSON's grammar is small and self-referential (an array holds values, which may themselves be arrays), which makes it a perfect fit for recursive descent: a function for each grammar rule, calling down into others when it hits nesting. You're rebuilding JSON.parse.
Two ideas carry the whole parser. First, one cursor: a single index i into the string that every function advances as it consumes characters, so parsing is just "look at text[i], decide what it starts, read it, move i past it." Second, dispatch by first character: a parseValue function peeks at the current character and delegates — { → object, [ → array, " → string, a digit or - → number, t/f/n → a literal.
The notorious shortcut is eval:
function jsonParseNaive(text) {
return eval('(' + text + ')');
}
It "works" for valid JSON, and that's exactly the trap. eval executes its input as JavaScript, so it happily runs jsonParseNaive('alert(1)') or jsonParseNaive('{a: 1+1}') — arbitrary code and non-JSON syntax that real JSON.parse rejects. It's a serious security hole on any untrusted input, and it doesn't enforce the JSON grammar. We need a parser that recognizes only JSON and throws on anything else.
function jsonParse(text) {
let i = 0; // the single cursor
const error = (msg) => {
throw new SyntaxError(`${msg} at position ${i}`);
};
const skipWs = () => {
while (i < text.length && ' \t\n\r'.includes(text[i])) i++;
};
function parseValue() {
skipWs();
const ch = text[i];
if (ch === '{') return parseObject();
if (ch === '[') return parseArray();
if (ch === '"') return parseString();
if (ch === '-' || (ch >= '0' && ch <= '9')) return parseNumber();
if (text.startsWith('true', i)) return (i += 4), true;
if (text.startsWith('false', i)) return (i += 5), false;
if (text.startsWith('null', i)) return (i += 4), null;
error('Unexpected token');
}
function parseObject() {
i++; // consume '{'
const obj = {};
skipWs();
if (text[i] === '}') return i++, obj; // empty
while (true) {
skipWs();
if (text[i] !== '"') error('Expected string key');
const key = parseString();
skipWs();
if (text[i] !== ':') error("Expected ':'");
i++;
obj[key] = parseValue(); // recurse
skipWs();
if (text[i] === ',') { i++; continue; }
if (text[i] === '}') return i++, obj;
error("Expected ',' or '}'");
}
}
function parseArray() {
i++; // consume '['
const arr = [];
skipWs();
if (text[i] === ']') return i++, arr; // empty
while (true) {
arr.push(parseValue()); // recurse
skipWs();
if (text[i] === ',') { i++; continue; }
if (text[i] === ']') return i++, arr;
error("Expected ',' or ']'");
}
}
function parseString() {
i++; // opening quote
let out = '';
const escapes = { '"': '"', '\\': '\\', '/': '/', n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' };
while (i < text.length) {
const ch = text[i++];
if (ch === '"') return out;
if (ch === '\\') {
const e = text[i++];
if (e === 'u') {
out += String.fromCharCode(parseInt(text.slice(i, i + 4), 16));
i += 4;
} else if (e in escapes) {
out += escapes[e];
} else error('Invalid escape');
} else {
out += ch;
}
}
error('Unterminated string');
}
function parseNumber() {
const start = i;
if (text[i] === '-') i++;
while (text[i] >= '0' && text[i] <= '9') i++;
if (text[i] === '.') { i++; while (text[i] >= '0' && text[i] <= '9') i++; }
if (text[i] === 'e' || text[i] === 'E') {
i++;
if (text[i] === '+' || text[i] === '-') i++;
while (text[i] >= '0' && text[i] <= '9') i++;
}
return Number(text.slice(start, i));
}
const result = parseValue();
skipWs();
if (i < text.length) error('Unexpected trailing characters');
return result;
}
module.exports = { jsonParse };
Each function owns one grammar rule and follows the same contract: assume i points at the start of its construct, consume exactly the characters it recognizes, and leave i just past them. parseValue is the dispatcher. parseObject and parseArray loop, calling parseValue for each nested value — that recursion is what lets arbitrary nesting parse with no special cases. parseString decodes escapes character by character; parseNumber scans the sign/fraction/exponent shape and hands the slice to Number. skipWs is sprinkled between tokens to ignore whitespace. Finally, after the top-level parseValue, we skip trailing whitespace and require the string to be fully consumed — that check is what makes '{}x' a syntax error instead of silently ignoring the x.
Take jsonParse('{"a": [1]}'):
parseValue — skipWs, sees { → parseObject.parseObject — consumes {; not empty; reads the key with parseString → 'a'; consumes :; calls parseValue for the value.
parseValue — sees [ → parseArray.
parseArray — consumes [; calls parseValue → parseNumber reads 1. After the element, sees ] → returns [1].obj.a = [1]. Back in parseObject: sees } → returns { a: [1] }.skipWs; i is at the end of the string, so no trailing-character error. Return { a: [1] }.The [1] parsed via a nested parseValue → parseArray → parseNumber call chain, three levels deep on the call stack — exactly mirroring the three levels of nesting in the text.
eval is not a parser — it executes arbitrary JavaScript and accepts non-JSON. Never use it on untrusted input; write a real parser that enforces the grammar.i just past what it consumed. An off-by-one here desynchronizes every function after it.i reached the end. Otherwise '1 2' or '{}x' parse the first token and silently ignore the rest.\n, escaped quotes, and \uXXXX. Decode escapes in parseString, and treat an unterminated string as an error.reviver argument — JSON.parse(text, reviver) walks the parsed result, letting you transform values (e.g. revive ISO date strings into Dates). It's a post-order traversal layered on top of the parser.onKey, onValue) as bytes arrive instead of building the whole tree in memory.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.