Why My JSON Formatter Has Its Own Parser Instead of JSON.parse

You paste an API response into an online formatter to read it, and copy an order ID out of the pretty version. The ID doesn’t exist. The formatter quietly changed 12345678901234567890 into 12345678901234567000, because it read your JSON with JSON.parse. Or the formatter tells you Expected ',' or '}' after property value in JSON at position 52, and you count characters in a 3,000-line file.

A JSON formatter seems like the one tool where the browser does all the work. When I built ToolOnTap’s JSON formatter, I ended up writing my own parser instead, for three reasons.

Problem one: JSON.parse changes numbers

JavaScript has one number type, a 64-bit float. It holds every integer exactly up to 2^53 − 1, which is 9,007,199,254,740,991, a 16-digit number. Beyond that, JSON.parse rounds to the nearest number it can store. The JSON grammar has no such limit, since a number is just digits, though RFC 8259 warns that software may not handle numbers beyond that range the same way.

This is what a format done with JSON.parse and JSON.stringify does to some ordinary-looking values (run in Node 20):

In your JSON After JSON.parse and JSON.stringify
12345678901234567890 12345678901234567000
9007199254740993 9007199254740992
1.50 1.5
1e400 null
-0 0
"café" "café"

Some of those are harmless. A 19-digit ID that changes, or a price that loses its trailing zero before a strict comparison, is not. And a formatter that changes your data is no longer a formatter.

So my parser never converts a value. A number, string, true, false or null is kept as the exact text it was written as:

export type JsonNode =
  | { kind: 'object'; entries: { key: string; raw: string; value: JsonNode }[] }  // key: decoded, raw: as written
  | { kind: 'array'; items: JsonNode[] }
  | { kind: 'value'; raw: string }                                                 // string, number, true, false, null

The printer writes raw back out. For ordinary JSON, the output is identical to JSON.stringify(value, null, indent), which a test checks with 2 spaces, 4 spaces and tabs. For JSON that JSON.parse would change, the output keeps every digit and every escape.

Keeping the text doesn’t mean accepting anything. Numbers must match the grammar in RFC 8259, the JSON standard, and the parser uses a sticky regular expression (the y flag) so it matches exactly at the current position without slicing the string:

const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y

That rules out 01, .5, 1. and +1, which are all valid JavaScript but not valid JSON. A test runs a list of edge cases through both my parser and JSON.parse and checks that they agree on what’s valid.

Problem two: errors you can act on

JSON.parse only tells you whether the text is valid, and its error messages depend on the browser. In Node 20, which uses the same engine as Chrome, a missing comma on line 4 gives Expected ',' or '}' after property value in JSON at position 52. Firefox and Safari word the same error differently.

My parser knows where it is, so every error has a line and a column, and a message written for the person, not the parser. The position is worked out only when something goes wrong, by counting line breaks up to the error:

export function positionAt(text: string, offset: number): JsonPosition {
  let line = 1
  let lineStart = 0
  for (let i = 0; i < offset && i < text.length; i++) {
    if (text.charCodeAt(i) === 10) { line++; lineStart = i + 1 }
  }
  return { offset, line, column: offset - lineStart + 1 }
}

The messages target the mistakes people actually make. Most broken JSON started as JavaScript, Python or a hand-edited config file:

Input Message
{"a": 1,} Trailing comma: remove the comma before }.
{'a': 1} JSON strings need double quotes ("), not single quotes.
{a: 1} Property names need double quotes, like “name”.
{"a": 1 // note JSON doesn’t allow comments. Remove the comment.
{"a": undefined} undefined isn’t a JSON value. Use null, or a number or string instead.
{"a": True} JSON is case-sensitive: write true.
{"a": None} JSON is case-sensitive: write null.
[1 2] Missing comma between two items.
{"a": 1} {"b": 2} Unexpected text after the end of the JSON. There may be a missing [ or { at the start, or more than one value.

True and None are there because people paste Python output. “Missing comma” needed its own check: when the parser finishes a value and sees a " where a comma or } should be, the missing comma is almost always the cause, so it says that instead of “unexpected character”.

The tool also doesn’t try to repair JSON. Guessing where a quote or a bracket should go could change what the data means. It points to the exact spot and lets you fix it.

Problem three: duplicate keys

{"a": 1, "a": 2} is valid JSON. RFC 8259 only says that names within an object SHOULD be unique, and that software behaves unpredictably when they aren’t. JSON.parse keeps the last value without a word, so in a formatter built on it, the first "a" simply disappears.

My parser keeps both entries and adds a warning with the position of the second one:

const key = JSON.parse(raw) as string  // the token is already validated
if (seen.has(key)) warnings.push({ ...positionAt(text, keyAt), message: `Duplicate key ${raw}. Most programs keep only the last one.` })
seen.add(key)

This is the one place it uses JSON.parse, on a single string token that has already been validated, to decode its escapes. Comparing decoded keys means "a" and "a" count as the same key, which is how any program reading the JSON will see them. The formatted output still shows both entries, as written, so you can decide which one to keep.

Staying safe on bad input

A parser for text people paste has to survive anything. Three limits keep it predictable:

  • Depth. The parser is recursive, so [[[[… nested a hundred thousand times could exhaust the call stack. It stops at 1,000 levels with a clear message instead.
  • Size. The text box and file picker accept up to about 20 MB. Beyond that the browser’s text box itself becomes the slow part.
  • Typing. The parser runs 150 ms after you stop typing, or 400 ms for text over 200,000 characters, so a large paste doesn’t re-parse on every keystroke.

All of it is about 200 lines of TypeScript with no dependencies, which also means it parses the same way in every browser.

What it gives you

Paste JSON or open a .json file in the JSON formatter. Format it with 2 spaces, 4 spaces or tabs, minify it, or sort keys at every level so two files can be compared in a diff. Your values stay exactly as you wrote them. More tools for this kind of work are on the developer tools page.

Tools in this article

  • JSON formatter and validator

    Format, validate and minify JSON in your browser. Clear errors with line and column, big numbers kept exact, duplicate keys flagged. No upload.

More articles