Paste this into your console:
try { JSON.parse('{"a":NaN}') } catch (e) { console.log(e.message) }
You get:
Unexpected token 'N', "{"a":NaN}" is not valid JSON
It found the problem. It quoted the document back at you. It named the exact character. And it did not tell you where.
For a nine-character document that's fine, you can see it. For the 260KB file a user just pasted into your editor, "there is an N somewhere" is not an error message, it's a riddle.
Which errors carry a position, and which don't
V8 has two message shapes, and only one of them has an offset. Here's every case I could think of that people actually hit:
| broken input | position in message? | message |
|---|---|---|
{"a":1,} |
7 | Expected double-quoted property name in JSON at position 7 |
{'a':1} |
1 | Expected property name or '}' in JSON at position 1 |
{a:1} |
1 | Expected property name or '}' in JSON at position 1 |
{"a":"x\qy"} |
8 | Bad escaped character in JSON at position 8 |
{"a":01} |
6 | Unexpected number in JSON at position 6 |
{"a":NaN} |
none | Unexpected token 'N', … is not valid JSON |
{"a":undefined} |
none | Unexpected token 'u', … is not valid JSON |
{"a":True} |
none | Unexpected token 'T', … is not valid JSON |
{"a":{"b":{"c":,}}} |
none | Unexpected token ',', … is not valid JSON |
{"a": |
none | Unexpected end of JSON input |
Look at which half is which. The messages with a position are the syntactic near-misses: a trailing comma, a single quote, a bad escape. The messages without one are NaN, undefined, Python's True, a stray comma in a nested object, and a truncated file.
That second list is what actually arrives in a text box. It's what you get from hand-edited config, from a Python repr pasted into the wrong window, from a download that got cut off. The engine goes quiet exactly when the document is most confusing.
The obvious fix, and why I threw it away
If JSON.parse won't say where it broke, ask it repeatedly. Binary search the prefix: parse text.slice(0, mid), and if it fails with "unexpected end" you're still inside valid JSON, so go right; any other error means you've gone past the break, so go left.
It works. I shipped it. It has two problems, and the second one is fatal.
It's slow. Every probe is a full parse of a prefix, so you pay O(n log n) character-reads to find one offset. On a 260KB document that's about 20 reparses.
It's keyed to one engine's prose. To decide "still inside valid JSON" you have to pattern-match the error message. Mine keyed off is not valid JSON, a string that exists only in V8. So the check never matched in Safari, the search declined to run, and every Safari user got an error annotation on line 1 regardless of where the problem was. It failed silently, in one browser, in a way no test caught, because the tests ran in Node.
And Safari is worse than "differently worded". I ran the same ten documents through JavaScriptCore directly. The engine ships on macOS, so you can try this yourself:
/System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Helpers/jsc
It reports a position for none of them. Not for NaN, which V8 also declines, but also not for the trailing comma, the single quote, the unquoted key, the bad escape or the leading zero, all five of which V8 locates exactly:
| input | V8 | JavaScriptCore |
|---|---|---|
{"a":1,} |
position 7 | Property name must be a string literal |
{'a':1} |
position 1 | Single quotes (') are not allowed in JSON |
{a:1} |
position 1 | Expected '}' |
{"a":"x\qy"} |
position 8 | Invalid escape character q |
{"a":01} |
position 6 | Expected '}' |
{"a":NaN} |
none | Unexpected identifier "NaN" |
JavaScriptCore's messages are arguably better prose. "Single quotes are not allowed in JSON" beats "Expected property name or '}'". They are also useless for putting a marker in a gutter. On Safari, JSON.parse never tells you where. For anything.
Error.prototype.message is not an API. It is not in the spec, it is not stable across engines, and it changes between versions. V8 rewrote these messages in 2021 and again later. Anything you build on it is a bet that nobody uses another browser.
What I did instead: read the grammar
JSON's grammar fits on a napkin. Rather than asking the engine where the document stops being JSON, walk it and find out directly.
The whole approach is a recursive descent scanner that returns an offset instead of a value:
- the first character that cannot legally appear where it does, or
-
text.lengthif the document simply ends mid-value, or -
-1if the whole thing parses.
The core trick is that it doesn't build anything. It has no output, no AST, no allocation. It only moves a cursor and throws the offset when the cursor hits something impossible:
function findErrorOffset(text) {
let i = 0;
// Abort with the offset that broke it; caught at the bottom.
const bad = (at) => { throw at; };
const ranOut = () => bad(text.length);
function skipWs() {
while (i < text.length && " \t\n\r".includes(text[i])) i++;
}
function scanLiteral(word) { // true / false / null
for (const ch of word) {
if (i >= text.length) ranOut();
if (text[i] !== ch) bad(i);
i++;
}
}
// … scanString, scanNumber, scanArray, scanObject, scanValue …
try {
skipWs();
scanValue();
skipWs();
if (i < text.length) bad(i); // trailing junk
return -1; // parses cleanly
} catch (offset) {
if (typeof offset === "number") return offset;
throw offset; // a real bug, not a parse result
}
}
Throwing a number as a control-flow signal is the part that looks wrong and is the reason it stays simple: every scan function can bail from arbitrary depth without threading a result type through the whole descent. Catching typeof offset === "number" keeps genuine bugs distinguishable from the signal.
One detail worth stealing: don't validate leading zeros in the number scanner. For {"a":01}, let scanNumber consume the 0, return, and leave the 1 for the caller, which rejects it as trailing junk at exactly the offset V8 names. Handling it inside the number scanner produces an off-by-one against every engine.
Does it agree with the engine?
The scanner is only trustworthy if it lands where JSON.parse lands. So: mutate valid documents at random, keep the cases where V8 does report a position, and compare.
200,000 mutations (substitutions, insertions and deletions against nine seed documents) produced 78,360 cases where V8 gave an offset:
compared 78360 cases where V8 reported a position
agreed: 78360 (100.00%)
Zero mismatches. Where V8 knows the answer, the scanner gets the same one; the value of the scanner is the other 60% of failures, where V8 knows nothing.
V8's own position still wins wherever there is one. The scanner is the fallback, not the replacement. The engine's message is the reason shown next to the marker, so its offset and its prose should agree.
Speed
A 260KB document (4,000 objects) with a NaN injected near the end:
| approach | per call |
|---|---|
| grammar scanner | 1.16 ms |
binary search over JSON.parse
|
9.54 ms |
About 8x, and it holds up under a debounced keystroke handler, which is what I needed it for. The scanner reads each character once; the binary search parses a ~130KB prefix twenty times.
The ceiling, stated honestly
It's recursive, so a document nested deeper than the JS stack throws RangeError. The fix is a try/catch that returns -1, with no location, rather than letting a stack overflow escape into a render:
} catch (offset) {
if (typeof offset === "number") return offset;
return -1; // RangeError from deep nesting: no location beats a crash
}
Rewriting the descent against an explicit stack would remove the limit. I haven't, because I've never seen a real document reach it, and an unbounded loop is harder to read than a recursive one. If you're parsing untrusted input at scale, that trade goes the other way.
The general point
Three times now I've been bitten by treating an error message as data: JSON positions here, XML parse errors in the same project (Gecko says Line Number N, Blink and WebKit print libxml2's error on line N at column N, and matching only the first meant every error annotated line 1), and a Node ENOENT check that broke when the path got quoted differently.
If you need a fact about why something failed, and the platform only offers it inside a human-readable sentence, that fact is not available. Either derive it yourself or do without it. The sentence will change.
This came out of building Easy Formatter, a set of browser-only JSON, XML and text tools. The JSON editor is where the scanner ended up, marking the line and column in the gutter. It runs entirely client-side, which is why "just parse it on the server and return a proper error object" wasn't available to me.
The fuzz harness, if you want to run it against your own implementation:
const seeds = [
'{"a":1,"b":[1,2,3],"c":{"d":"e"}}',
'[1,2,{"x":null},true,false,-1.5e10]',
'{"nested":{"deep":{"deeper":[{"k":"v"}]}}}',
'[]', '{}', '"bare"', '42', 'null',
];
const chars = '{}[]",:0123456789abcdefnrtu \\\n\t-.eE+';
let checked = 0, agree = 0;
for (let n = 0; n < 200000; n++) {
const seed = seeds[(Math.random() * seeds.length) | 0];
const i = (Math.random() * seed.length) | 0;
const c = chars[(Math.random() * chars.length) | 0];
const op = Math.random();
const t = op < 0.4 ? seed.slice(0, i) + c + seed.slice(i + 1)
: op < 0.7 ? seed.slice(0, i) + c + seed.slice(i)
: seed.slice(0, i) + seed.slice(i + 1);
let v8;
try { JSON.parse(t); continue; } catch (e) {
const m = /position (\d+)/.exec(e.message);
if (!m) continue; // no position: nothing to compare
v8 = Number(m[1]);
}
checked++;
if (findErrorOffset(t) === v8) agree++;
}
console.log(`${agree}/${checked} agreed`);
Top comments (0)