# Build a JSON Formatter & Validator (JS)

> Build a JSON pretty-printer and validator in JavaScript - parse, format, explain errors, and check shape - runnable right in your browser.


---

# Build a JSON Formatter & Validator (JS)

You have pasted ugly JSON into a tool more times than you can count. A single line, no spaces, a missing comma somewhere, and a browser tab that screams "Unexpected token" without telling you where. This weekend you and I are building the tool that does it better - and you'll understand every line of it.

By the end you'll have a small JavaScript module that takes raw JSON text and:

- pretty-prints it with clean indentation,
- catches broken JSON and explains the problem in plain language with a rough location,
- checks the data against a shape you describe (required keys, expected types),
- minifies it back down and sorts the keys for stable output.

No frameworks. No build step. No npm install. Everything here is plain JavaScript using `JSON.parse` and `JSON.stringify`, which already live in every browser and every Node runtime.

## This one runs in your browser

This is a run-along project. Every code block on these pages is a real, runnable snippet - press the run button and you'll see the output right there on the page. You don't need to set anything up on your machine. Read, run, tweak a value, run again. That loop is the whole point.

Because each block runs fresh and on its own, every example re-declares what it needs and ends with a `console.log`. When you build the real thing later, you'd keep these as functions in one file and call them. Here, each one stands alone so you can poke at it in isolation.

## The stack

| Piece | What we use | Why |
| --- | --- | --- |
| Parsing | `JSON.parse` | Built in, strict, gives us error messages |
| Formatting | `JSON.stringify` | Has an indent argument most people never use |
| Errors | `try` / `catch` | Turn a thrown error into a friendly report |
| Shape check | plain functions + `typeof` | No schema library needed for this |

## The shape of the build

```mermaid
graph LR
  A[Raw text] --> B[Parse]
  B -->|ok| C[Pretty-print]
  B -->|fails| D[Explain error]
  C --> E[Shape check]
  E --> F[Minify / sort]
```

Each phase adds one box. Phase 1 gets text in and pretty JSON out. Phase 2 handles the day every developer has, where the JSON is broken. Phase 3 asks "is this the data I expected?" Phase 4 squeezes it back down and gives you a few directions to take it further.

## What you'll learn

- The second argument to `JSON.parse` and the third to `JSON.stringify` - the parts almost nobody reads about.
- How to catch a thrown error and pull useful information out of it.
- How to walk an object and compare it to an expected structure without reaching for a library.
- Where to draw the line between "good enough for me" and "I'd ship this," and how to extend it past that line.

Rough time: about two hours if you run every block and tinker. Less if you skim, but skimming a run-along project is like reading a recipe without tasting anything.

Difficulty: beginner. If you've written a function and called it, you're ready. If you've never touched JSON, here's the one-sentence version - it's a text format for data that looks like JavaScript objects and arrays, and it's how most web APIs talk.

Grab a drink. Phase 1 is short and you'll have working output in five minutes.


---

# Parse and Pretty-Print

The core of a JSON formatter is two function calls. Text comes in, becomes a real JavaScript value, then becomes text again - but the second time, with indentation. That round trip is the whole trick, and once you see it you'll wonder why you ever pasted JSON into a website.

## Text is not data

When an API sends you JSON, you get a *string*. It looks like an object, but to JavaScript it's a wall of characters. You can't read `.name` off it. You have to parse it first.

Guess what `typeof raw` and `typeof data` will print before you run it.

```js runnable
const raw = '{"name":"Ada","langs":["Pascal","Ada"]}';

console.log("type of raw:", typeof raw);

const data = JSON.parse(raw);
console.log("type after parse:", typeof data);
console.log("now we can reach in:", data.name);
```

`JSON.parse` reads the string and hands back the real thing - an object you can index into. Run that and you'll see the type change from `string` to `object`, and `data.name` gives you `Ada`.

## Going the other way, with spacing

`JSON.stringify` does the reverse: a value goes in, a string comes out. Most people stop at `JSON.stringify(data)` and get back the same cramped single line. The fix is the *third argument* - the number of spaces to indent with.

```js runnable
const data = { name: "Ada", langs: ["Pascal", "Ada"] };

console.log("--- no indent ---");
console.log(JSON.stringify(data));

console.log("--- indent of 2 ---");
console.log(JSON.stringify(data, null, 2));
```

That `null` in the middle is the *replacer* slot - a hook for transforming values as they're written. We don't need it yet, so we pass `null`. The `2` is what matters: two spaces per level. Try changing it to `4`, or to the string `"\t"` for tabs, and run it again.

## Putting both halves together

A formatter is parse-then-stringify: text goes in, becomes real data, then becomes text again - this time with the indentation you ask for.

**Your turn.** This `format` function is the spine of everything we build from here, so have a go before you read on. Fill it in and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```js runnable
function format(text, indent = 2) {
  // Parse `text` as JSON, then turn it back into a string using the
  // given indent (spaces per level). Return the formatted string.
}

// --- checks: fix your function until this prints "All good." ---
const out1 = format('{"a":1}');
if (out1 !== '{\n  "a": 1\n}') {
  throw new Error(`format('{"a":1}') should indent with 2 spaces, got: ${out1}`);
}

const out2 = format('{"a":1}', 4);
if (out2 !== '{\n    "a": 1\n}') {
  throw new Error(`format with indent=4 should use 4 spaces, got: ${out2}`);
}

const out3 = format('{"id":7,"tags":["a","b"]}');
if (JSON.parse(out3).tags[1] !== "b") {
  throw new Error(`the formatted output should still parse back to the same data, got: ${out3}`);
}

console.log("All good.");
```

Stuck? You already wrote both halves of this in the two blocks above - `format` just needs to call them in order and return the result.

### One way to write it

```js runnable
function format(text, indent = 2) {
  const data = JSON.parse(text);
  return JSON.stringify(data, null, indent);
}

const messy = '{"id":7,"tags":["a","b"],"meta":{"draft":true,"views":0}}';

console.log(format(messy));
```

Run it. The cramped input comes out laid out across multiple lines, nested objects indented under their parents, arrays spaced cleanly. That `format` function is the spine of everything we build from here.

Notice what `JSON.parse` did for free along the way: it *normalized* the data. Whatever odd-but-legal spacing was in the input is gone, replaced by exactly the indentation you asked for. Before you run this one, guess whether the output looks any different from the last block's, even though the input is formatted totally differently:

```js runnable
function format(text, indent = 2) {
  return JSON.stringify(JSON.parse(text), null, indent);
}

const ugly = '{ "a" :1,   "b":[  2,3 ,  4],"c"   : { "d" : true } }';

console.log(format(ugly));
```

Same clean result. The parser threw away the input's spacing and the stringifier rebuilt it consistently. That's why "format this JSON" and "is this JSON valid" are answered by the same two calls - if it parses, it formats.

## What JSON.parse is strict about

JSON looks like JavaScript, but it's a stricter dialect. The parser will reject things that are fine in your code:

| Allowed in JS | Allowed in JSON? |
| --- | --- |
| Single quotes `'x'` | No - keys and strings need double quotes |
| Trailing comma `[1,2,]` | No |
| Unquoted keys `{a:1}` | No - keys must be quoted strings |
| Comments `// note` | No |
| `undefined` | No - use `null` |

This strictness is a feature. It means valid JSON parses the same way everywhere, in every language. It also means the day your input is *not* valid, the parser throws - and right now our `format` function would crash and take the page with it.

Run this to watch it fail. The error is real; the page survives because the block is isolated, but in a real tool an uncaught throw stops everything:

```js runnable
function format(text, indent = 2) {
  return JSON.stringify(JSON.parse(text), null, indent);
}

const broken = '{"name":"Ada", "langs":["Pascal","Ada",]}'; // trailing comma

try {
  console.log(format(broken));
} catch (err) {
  console.log("It threw:", err.message);
}
```

There's the trailing comma biting us. The raw message is cryptic and the line number is useless for a one-line string. We can do far better than `Unexpected token`.

That's exactly Phase 2: catching that throw and turning it into something a human can act on - what broke, and roughly where. You've already got a working pretty-printer. Next we make it clear about failure.


---

# Explaining Errors

Last phase ended with our formatter throwing `Unexpected token` and a line number that meant nothing. A tool that fails that way is worse than no tool. This phase is about catching the throw and turning it into something you'd actually want to read: what went wrong, and where to look.

## Don't let the throw escape

The first move is the cheapest one. Wrap the parse in `try` / `catch` so a bad input becomes a *result* you can hand back, not a crash.

Guess which of the two calls below comes back with `ok: true` before you run it.

```js runnable
function tryFormat(text) {
  try {
    const data = JSON.parse(text);
    return { ok: true, output: JSON.stringify(data, null, 2) };
  } catch (err) {
    return { ok: false, message: err.message };
  }
}

console.log(tryFormat('{"a":1}'));
console.log(tryFormat('{"a":1,}'));
```

Now every call returns an object. `ok: true` carries the formatted output; `ok: false` carries the error message. Nothing throws past our function. That alone makes this safe to wire into a page - but the message is still raw browser text.

## Where did it break?

Modern JavaScript engines tuck a character position into the error message - often as `position N` or `(line L column C)`. The exact wording differs between browsers, which is annoying, but the *number* is gold. If we can pull a position out, we can show the reader the spot.

```js runnable
const broken = '{"name":"Ada", "age":}'; // value missing after age

try {
  JSON.parse(broken);
} catch (err) {
  console.log("raw message:", err.message);
}
```

Run it and read the raw message your browser produced. Somewhere in there is a number telling you how many characters in the problem is. `findPosition` below digs that number out with a regular expression - it's given, since it's a one-liner. The real work is `lineAndColumn`: translating a raw character count into a line and column a human can count to.

**Your turn.** Fill in `lineAndColumn` and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```js runnable
function findPosition(message) {
  // Engines say "position 21" or "(line 1 column 22)" - grab whichever.
  const posMatch = message.match(/position (\d+)/);
  if (posMatch) return Number(posMatch[1]);
  return null;
}

function lineAndColumn(text, position) {
  // Given the full `text` and a 0-based character `position` where the
  // parser choked, return { line, column } - both 1-based, the way a
  // human counts lines and characters in an editor.
}

// --- checks: fix your function until this prints "All good." ---
const single = '{"name":"Ada", "age":}';
const r1 = lineAndColumn(single, 21);
if (JSON.stringify(r1) !== JSON.stringify({ line: 1, column: 22 })) {
  throw new Error(`single-line text at position 21 should be {line:1, column:22}, got: ${JSON.stringify(r1)}`);
}

const multi = '{\n  "name": "Ada",\n  "age":\n}';
const r2 = lineAndColumn(multi, multi.indexOf("}"));
if (JSON.stringify(r2) !== JSON.stringify({ line: 4, column: 1 })) {
  throw new Error(`the closing brace is line 4, column 1, got: ${JSON.stringify(r2)}`);
}

console.log("All good.");
```

Stuck? `text.lastIndexOf("\n")` returns `-1` when there's no newline at all. Whatever formula you use for "distance from the last newline" needs to keep working with that `-1`, for both single-line and multi-line text.

### One way to write it

```js runnable
function findPosition(message) {
  // Engines say "position 21" or "(line 1 column 22)" - grab whichever.
  const posMatch = message.match(/position (\d+)/);
  if (posMatch) return Number(posMatch[1]);
  return null;
}

function lineAndColumn(text, position) {
  const before = text.slice(0, position);
  const line = before.split("\n").length;
  const lastNewline = before.lastIndexOf("\n");
  const column = position - lastNewline; // 1-based within the line
  return { line, column };
}

const broken = '{"name":"Ada", "age":}';
const pos = 21; // pretend the message gave us this

console.log("char position:", pos);
console.log("location:", lineAndColumn(broken, pos));
```

`findPosition` reads the number out of whatever the engine said. `lineAndColumn` counts newlines before that point to work out the line, then measures the distance from the last newline to get the column. For a one-line input the line is always 1 and the column is what you care about.

If you computed the column as `position - lastNewline - 1`, that's a reasonable instinct - it's the plain 0-based offset into the line, which is how most string methods think. But editors and humans count columns starting at 1, so that version lands one character before the real spot. The caret in the next section still points close enough to be useful even when it's off by one, which is exactly why an off-by-one bug like that can slip through unnoticed for a while.

## A caret that points at the spot

Numbers are fine, but the kindest thing a formatter can do is draw an arrow at the broken character. We slice the text around the position and put a `^` underneath.

Guess which character the caret lands under before you run it - count 21 characters into `broken` by hand if you want to check yourself.

```js runnable
function pointAt(text, position) {
  const lines = text.split("\n");
  let remaining = position;
  let lineIndex = 0;

  // Walk lines until the position falls inside one.
  while (lineIndex < lines.length && remaining > lines[lineIndex].length) {
    remaining -= lines[lineIndex].length + 1; // +1 for the newline
    lineIndex += 1;
  }

  const badLine = lines[lineIndex] ?? "";
  const caret = " ".repeat(Math.max(0, remaining)) + "^";
  return badLine + "\n" + caret;
}

const broken = '{"name":"Ada", "age":}';
console.log(pointAt(broken, 21));
```

Run it and you get the line with a caret sitting under the character the parser choked on. For broken JSON that's usually right at - or one past - the real mistake, which is close enough to find it by eye.

## Wiring it into one clear formatter

Now we fold parsing, error catching, position-finding, and the caret into a single function that returns a clean result either way.

```js runnable
function findPosition(message) {
  const m = message.match(/position (\d+)/);
  return m ? Number(m[1]) : null;
}

function pointAt(text, position) {
  const slice = text.slice(0, position);
  const caret = " ".repeat(Math.max(0, position)) + "^";
  return text.split("\n")[0] + "\n" + caret;
}

function format(text) {
  try {
    return { ok: true, output: JSON.stringify(JSON.parse(text), null, 2) };
  } catch (err) {
    const pos = findPosition(err.message);
    const report = {
      ok: false,
      message: "Couldn't parse the JSON: " + err.message,
    };
    if (pos !== null) report.where = "\n" + pointAt(text, pos);
    return report;
  }
}

// A good one and three classic breakages.
console.log(format('{"name":"Ada","age":36}'));
console.log("");
console.log(format('{"name":"Ada","age":}'));      // missing value
console.log("");
console.log(format('{"name":"Ada" "age":36}'));    // missing comma
console.log("");
console.log(format("{'name':'Ada'}"));             // single quotes
```

Run the whole thing. The valid object formats cleanly. The three broken ones each come back with a message and, where the engine gave us a position, a caret under the trouble spot. Three of the most common JSON mistakes - missing value, missing comma, single quotes - now produce a result you can act on instead of a stack trace.

Your formatter no longer crashes on bad input, and it explains what it found. Next we ask a harder question: the JSON parsed fine, but is it the *right* data? That's the shape check in Phase 3.


---

# A Tiny Shape Check

Valid JSON and *correct* JSON are different things. `{}` parses fine. So does `{"age":"thirty"}`. Your formatter from Phase 2 is happy with both, and neither is the user record your code expected. This phase adds the missing question: does this data have the keys I need, with values of the types I need?

We're not building a schema library. We're writing about thirty lines that answer "is this the shape I asked for?" - and that turns out to cover most of what you actually check by hand.

## Describe the shape with an object

The simplest way to say what you expect is another object: each key maps to the type its value should be. We'll use the strings `JSON.parse` would produce, plus `"array"` since JavaScript reports arrays as `"object"` and we want to tell them apart.

Guess what plain `typeof` would say for `[1, 2, 3]` and for `null` before you run this - both are the reason this wrapper exists.

```js runnable
function typeOf(value) {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value; // "string", "number", "boolean", "object"
}

console.log(typeOf("hi"));
console.log(typeOf(42));
console.log(typeOf([1, 2, 3]));
console.log(typeOf({ a: 1 }));
console.log(typeOf(null));
console.log(typeOf(true));
```

`typeOf` is the one helper we need. Plain `typeof` calls an array an object and calls `null` an object - both misleading. This wrapper sorts those two out so the rest of the check can trust what it gets back.

## Compare data to the shape

Now the check itself: something that takes parsed `data` and an expected `shape`, and tells you exactly what's wrong with it - if anything.

**Your turn.** `typeOf` is given below, unchanged. `checkShape` is the point of this phase, so have a go before you read on. Fill it in and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```js runnable
function typeOf(value) {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value;
}

function checkShape(data, shape) {
  // `shape` maps each expected key to the type string it should have
  // (as typeOf would report it: "string", "number", "boolean", "array",
  // "object", "null").
  //
  // Return an array of human-readable problem strings:
  //   - if `data` itself isn't an object, return a single problem saying so
  //   - for every key in `shape` missing from `data`, add a problem
  //   - for every key present with the wrong type, add a problem
  //   - collect ALL problems, don't stop at the first
  // An empty array means "data matches the shape."
}

// --- checks: fix your function until this prints "All good." ---
const shape = { name: "string", age: "number", admin: "boolean" };

const good = { name: "Ada", age: 36, admin: true };
const goodProblems = checkShape(good, shape);
if (!Array.isArray(goodProblems) || goodProblems.length !== 0) {
  throw new Error(`a fully matching object should report no problems, got: ${JSON.stringify(goodProblems)}`);
}

const bad = { name: "Ada", age: "36" }; // age wrong type, admin missing
const badProblems = checkShape(bad, shape);
if (!Array.isArray(badProblems) || badProblems.length !== 2) {
  throw new Error(`age has the wrong type AND admin is missing - expected 2 problems, got: ${JSON.stringify(badProblems)}`);
}

const notObject = checkShape("nope", shape);
if (!Array.isArray(notObject) || notObject.length !== 1) {
  throw new Error(`a non-object top level should report exactly one problem, got: ${JSON.stringify(notObject)}`);
}

console.log("All good.");
```

Stuck on collecting every problem instead of stopping at the first? Push each problem onto an array as you find it, instead of returning the moment something's wrong.

### One way to write it

```js runnable
function typeOf(value) {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value;
}

function checkShape(data, shape) {
  const problems = [];

  if (typeOf(data) !== "object") {
    return ["Expected an object at the top level, got " + typeOf(data)];
  }

  for (const key of Object.keys(shape)) {
    const wanted = shape[key];
    if (!(key in data)) {
      problems.push('Missing key "' + key + '" (wanted ' + wanted + ")");
      continue;
    }
    const got = typeOf(data[key]);
    if (got !== wanted) {
      problems.push('Key "' + key + '" should be ' + wanted + ", got " + got);
    }
  }

  return problems;
}

const shape = { name: "string", age: "number", admin: "boolean" };

const good = { name: "Ada", age: 36, admin: true };
const bad = { name: "Ada", age: "36" }; // age wrong type, admin missing

console.log("good:", checkShape(good, shape));
console.log("bad: ", checkShape(bad, shape));
```

Walk the expected shape; for each key, confirm it exists in the data and that its value's type matches, collecting every problem instead of stopping at the first - one good report beats five round trips. Run it. The good record returns an empty array - no problems. The bad one returns two: `age` is a string where we wanted a number, and `admin` is missing entirely. An empty list means "passed." A non-empty list is your to-do list of fixes.

Notice we let extra keys slide. If the data has a `email` field our shape didn't mention, we don't complain. That's a deliberate choice - most of the time you care that the keys you *need* are right, not that nothing extra came along. We'll revisit that in the extend section next phase.

## Parse, then check, in one flow

The shape check works on parsed data, so it slots in right after the parse from Phase 2. Text in, and one of three outcomes out: didn't parse, parsed but wrong shape, or parsed and valid.

Three inputs go in below. Before you run it, guess which one hits `stage: "parse"`, which hits `stage: "shape"`, and which comes back valid.

```js runnable
function typeOf(value) {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value;
}

function checkShape(data, shape) {
  const problems = [];
  if (typeOf(data) !== "object") {
    return ["Expected an object, got " + typeOf(data)];
  }
  for (const key of Object.keys(shape)) {
    if (!(key in data)) {
      problems.push('Missing "' + key + '"');
    } else if (typeOf(data[key]) !== shape[key]) {
      problems.push('"' + key + '" should be ' + shape[key] + ", got " + typeOf(data[key]));
    }
  }
  return problems;
}

function validate(text, shape) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (err) {
    return { ok: false, stage: "parse", message: err.message };
  }

  const problems = checkShape(data, shape);
  if (problems.length > 0) {
    return { ok: false, stage: "shape", problems };
  }

  return { ok: true, output: JSON.stringify(data, null, 2) };
}

const shape = { name: "string", age: "number" };

console.log(validate('{"name":"Ada","age":36}', shape)); // valid
console.log(validate('{"name":"Ada","age":"36"}', shape)); // wrong type
console.log(validate('{"name":"Ada",}', shape));           // won't parse
```

Run all three. The first parses and matches the shape, so you get formatted output. The second parses but `age` is a string, so you get a shape problem. The third never parses, so you get a parse error and we never even reach the shape check. The `stage` field tells you which wall it hit.

## How far would you push this?

What we've got handles flat objects of basic types - which is a big slice of real-world JSON. It does *not* check nested objects, types inside arrays, or optional-versus-required keys. Those are real, and they're a natural next step:

| Want | Sketch |
| --- | --- |
| Nested objects | Let a shape value *be* a shape, and recurse into it |
| Typed arrays | Shape value like `["string"]` meaning "array of strings" |
| Optional keys | Mark some keys with a `?` and skip the "missing" check for them |

Each is a few more lines on the same idea. Resist adding them until you have JSON that needs them - a check you don't use is a check you have to maintain for nothing.

You now have a tool that formats, explains failures, and judges shape. Phase 4 ties a bow on it: a minify mode for when you want the bytes back, stable key sorting, and a couple of directions to take it further.


---

# Minify and Extend

Pretty-printing makes JSON readable. Sometimes you want the opposite - the smallest possible string to send over the wire or stuff into a config field. This phase adds a minify mode, then a key-sorting option that makes output stable enough to diff, and finishes with directions to keep going after the weekend's over.

## Minify is the same call, minus the indent

You already know how to minify. It's `JSON.stringify` with no indent argument. Parse to clean out whatever spacing was there, stringify with no spacing to get the tightest valid form.

Guess roughly how many characters shorter the minified version will be before you run it and see the real number.

```js runnable
function minify(text) {
  return JSON.stringify(JSON.parse(text));
}

const spaced = `{
  "name": "Ada",
  "langs": [ "Pascal", "Ada" ],
  "active": true
}`;

const small = minify(spaced);
console.log(small);
console.log("from", spaced.length, "chars down to", small.length);
```

Run it. The multi-line input collapses to one line and you'll see the character count drop. Parsing first matters - it guarantees the output is valid JSON, not a clumsy find-and-replace on whitespace that would mangle spaces *inside* string values.

## One function, both directions

There's no reason to keep two functions. A `mode` argument picks pretty or compact, and both share the same parse.

```js runnable
function convert(text, mode = "pretty") {
  const data = JSON.parse(text);
  if (mode === "minify") return JSON.stringify(data);
  return JSON.stringify(data, null, 2);
}

const raw = '{"id":7,"tags":["x","y"]}';

console.log("--- pretty ---");
console.log(convert(raw, "pretty"));
console.log("--- minify ---");
console.log(convert(raw, "minify"));
```

Run it and watch the same input come out two ways. That's your formatter and your minifier in one place.

## Sorting keys for stable output

Here's a problem you hit the moment you try to compare two JSON files: object key order. `{"a":1,"b":2}` and `{"b":2,"a":1}` are the *same data*, but as text they're different lines, and any diff tool will light up red. The fix is to sort keys before stringifying, so the same data always produces the same text.

That third argument to `JSON.stringify` - the replacer we skipped in Phase 1 - has a second form worth knowing about. We'll use it here, alongside the function form from a moment ago.

**Your turn.** `sortedFormat` is the point of this phase - the thing that makes two files holding the same data produce identical text. Have a go before you read on. Fill it in and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```js runnable
function sortedFormat(text, indent = 2) {
  // Parse `text`, then stringify it with every key across the whole
  // structure written in alphabetical order (not just the top level).
  // Use `indent` spaces per level. Return the formatted string.
  //
  // Hint: JSON.stringify's second argument (the replacer) can be either
  // a function called for every key/value, or an array of key names to
  // include (in that order). You get to use both forms here.
}

// --- checks: fix your function until this prints "All good." ---
const messy = '{"name":"Ada","age":36,"admin":true,"name2":"x"}';
const out = sortedFormat(messy);
if (typeof out !== "string") {
  throw new Error(`sortedFormat should return a string, got: ${JSON.stringify(out)}`);
}

const keysInOrder = [...out.matchAll(/"(\w+)":/g)].map((m) => m[1]);
const expectedOrder = ["admin", "age", "name", "name2"];
if (JSON.stringify(keysInOrder) !== JSON.stringify(expectedOrder)) {
  throw new Error(`keys should come out alphabetically as ${JSON.stringify(expectedOrder)}, got: ${JSON.stringify(keysInOrder)}`);
}

const parsed = JSON.parse(out);
if (parsed.name !== "Ada" || parsed.age !== 36 || parsed.admin !== true || parsed.name2 !== "x") {
  throw new Error(`sorting keys should not change the values, got: ${out}`);
}

const nested = '{"b":{"z":1,"a":2},"a":1}';
const nestedOut = sortedFormat(nested);
const nestedKeys = [...nestedOut.matchAll(/"(\w+)":/g)].map((m) => m[1]);
if (JSON.stringify(nestedKeys) !== JSON.stringify(["a", "b", "a", "z"])) {
  throw new Error(`sorting should reach into nested objects too, got: ${JSON.stringify(nestedKeys)}`);
}

console.log("All good.");
```

Stuck on reaching the nested keys too? The function-form replacer runs once for every key anywhere in the structure, not just the top-level ones - that's how you can collect all of them in a single pass.

### One way to write it

```js runnable
function sortedFormat(text, indent = 2) {
  const data = JSON.parse(text);
  // Gather every key across the whole structure, sorted.
  const keys = new Set();
  JSON.stringify(data, (key, value) => {
    if (key) keys.add(key);
    return value;
  });
  const ordered = [...keys].sort();
  return JSON.stringify(data, ordered, indent);
}

const messy = '{"name":"Ada","age":36,"admin":true,"name2":"x"}';

console.log("--- as written ---");
console.log(JSON.stringify(JSON.parse(messy), null, 2));
console.log("--- keys sorted ---");
console.log(sortedFormat(messy));
```

That second form: pass the replacer an *array of keys* and it outputs only those keys, in that order - hand it the sorted list of every key and you get sorted output. We use the *replacer-as-function* form once to collect all keys, then the *replacer-as-array* form to emit them in sorted order. Run it. The first block keeps the original key order; the second alphabetizes every key. Now two files holding the same data, formatted this way, produce identical text - and a diff between them shows only real differences.

## The whole tool, assembled

Here's everything from all four phases in one place - parse, pretty-print, explain errors, check shape, minify, sort. This is the thing you set out to build.

```js runnable
function typeOf(v) {
  if (v === null) return "null";
  if (Array.isArray(v)) return "array";
  return typeof v;
}

function checkShape(data, shape) {
  const problems = [];
  if (typeOf(data) !== "object") return ["Expected an object, got " + typeOf(data)];
  for (const key of Object.keys(shape)) {
    if (!(key in data)) problems.push('Missing "' + key + '"');
    else if (typeOf(data[key]) !== shape[key]) {
      problems.push('"' + key + '" should be ' + shape[key] + ", got " + typeOf(data[key]));
    }
  }
  return problems;
}

function jsonTool(text, { mode = "pretty", sort = false, shape = null } = {}) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (err) {
    return { ok: false, stage: "parse", message: err.message };
  }

  if (shape) {
    const problems = checkShape(data, shape);
    if (problems.length) return { ok: false, stage: "shape", problems };
  }

  let replacer = null;
  if (sort) {
    const keys = new Set();
    JSON.stringify(data, (k, v) => (k && keys.add(k), v));
    replacer = [...keys].sort();
  }

  const indent = mode === "minify" ? undefined : 2;
  return { ok: true, output: JSON.stringify(data, replacer, indent) };
}

const raw = '{"name":"Ada","age":36,"admin":true}';

console.log(jsonTool(raw)); // pretty
console.log(jsonTool(raw, { mode: "minify" }));
console.log(jsonTool(raw, { sort: true }));
console.log(jsonTool(raw, { shape: { name: "string", age: "number" } }));
console.log(jsonTool('{"name":"Ada","age":"36"}', { shape: { name: "string", age: "number" } }));
console.log(jsonTool('{"name":}', {})); // broken
```

Run it. One function, one options object, every behavior we built. Pretty by default, minify on request, sorted on request, shape-checked when you hand it a shape, and a clean error report when the text won't parse. That's a real tool.

## Where to take it next

You've got a working formatter and validator. Here are directions worth a future afternoon - each builds on what's already here, none needs a library.

| Idea | The seed |
| --- | --- |
| Diff two JSONs | Parse both, walk keys, report added / removed / changed |
| Nested shape check | Let a shape value be another shape and recurse |
| Highlight | Wrap strings, numbers, keys in spans for colour in a page |
| Sort + diff combo | Sort both inputs first so the diff shows only real changes |

The diff is the most fun, so here's a starting sketch - a shallow compare of two flat objects that reports what changed.

Before you run it, count by eye: how many keys change value between `before` and `after`, and how many are brand new?

```js runnable
function diff(a, b) {
  const changes = [];
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
  for (const key of keys) {
    if (!(key in a)) changes.push("added " + key + " = " + JSON.stringify(b[key]));
    else if (!(key in b)) changes.push("removed " + key);
    else if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
      changes.push("changed " + key + ": " + JSON.stringify(a[key]) + " -> " + JSON.stringify(b[key]));
    }
  }
  return changes;
}

const before = { name: "Ada", age: 36, admin: false };
const after = { name: "Ada", age: 37, admin: true, email: "a@x.io" };

console.log(diff(before, after));
```

Run it. You get `age` changed, `admin` changed, and `email` added - a small, clear diff. Notice it uses `JSON.stringify` to compare values, the same trick that lets it tell `[1,2]` apart from `[1,3]` without writing a deep-equality function. Make it recurse into nested objects and you've got something genuinely useful.

That's the build. You started with a one-line mess and ended with a tool that formats it, tells you when it's broken and where, checks it's the data you meant, shrinks it back down, and can even tell you what changed between two versions - every line of which you understand, because you ran all of it yourself.
