JSON & Serialization
The lingua franca of APIs, plus the sharp edges: dates, big integers, and undefined.
20 minDifficulty 1/5data · apiReviewedRead and accepted by a person.
Why this exists
JSON looks like it needs no explanation — objects, arrays, strings, numbers, done. Then a date comes back as a string, a 64-bit id silently loses its last three digits, and a field you definitely set has vanished. Each of those is a documented, predictable behaviour of a format most people never actually read the rules for.
The mental model
A shipping crate. Everything must be flattened into a few standard shapes to travel. A date isn't a shape JSON has, so it gets flattened into text — and someone at the other end has to know to unflatten it.
How it works
Six types, and that's genuinely all
string, number, boolean, null, object, array. No dates, no integers-distinct-from-floats, no undefined, no functions, no Map or Set, no binary. Every rich type your language has must be encoded into one of those six and decoded on the other side. Most JSON bugs are exactly this: a type that survived the trip as a *lookalike* rather than as itself.
undefined disappears; null survives
`JSON.stringify` drops object properties whose value is `undefined` entirely — the key isn't emitted at all. `null` is a real JSON value and round-trips fine. This distinction has teeth in APIs: with PATCH semantics, `{ "nickname": null }` means "clear this field" while omitting the key means "leave it alone" — and if your client builds the body from an object with `undefined`, you'll send the second when you meant the first. Functions and `undefined` inside *arrays* behave differently again: they become `null` rather than vanishing, because arrays can't have holes.
Numbers are doubles, and large integers break silently
JSON numbers are IEEE-754 doubles in JavaScript, so integers above 2^53 lose precision. A Twitter-style 64-bit id like `9007199254740993` parses as `9007199254740992` — off by one, no error, no warning. This is why every serious API sends large ids **as strings**. The failure mode is nasty precisely because it's silent and only affects large values, so it passes every test written with small ids.
Parsing is a trust boundary
`JSON.parse` throws on malformed input, and an unguarded parse in a request handler is a crash waiting for its first bad request. But valid JSON isn't the same as *acceptable* JSON — parsing tells you the syntax is right, nothing more. The shape, the types, the ranges all still need validating, which is why runtime validation with something like Zod exists. In TypeScript this is especially sharp: `JSON.parse` returns `any`, so annotating the result as your type is a lie the compiler will happily believe.
The mechanism
What survives and what doesn't: ```js JSON.stringify({ date: new Date('2026-01-01'), // → "2026-01-01T00:00:00.000Z" (string) big: 9007199254740993, // → 9007199254740992 (wrong) bigint: 1n, // → throws TypeError fn: () => {}, // → key dropped undef: undefined, // → key dropped nothing: null, // → null (survives) set: new Set([1, 2]), // → {} (empty object) nested: [undefined, () => {}], // → [null, null] }); ``` The `Set` line is the quietest failure: no error, no warning, just an empty object where your data was.
flowchart LR
A[Rich object: Date, BigInt, Map] -->|stringify| B[Six JSON types only]
B -->|network| C[Text]
C -->|parse| D[Plain objects and primitives]
D -->|validate + revive| E[Rich object again]
D -.->|skip this step| F[Silent type bugs]What people get wrong
- JSON is a subset of JavaScript.
- Almost, but not exactly — and more importantly it's a language-independent format with its own specification. Treating it as JavaScript is how eval-based parsing happened, which is a remote code execution vulnerability. It also encourages assuming JS semantics that other languages don't share.
- Dates round-trip through JSON.
- stringify converts a Date to an ISO string; parse leaves it a string. There's no date type. Without an explicit revive step you get a string that looks like a date and fails the moment you call a Date method on it.
- Numbers are numbers, so ids are fine.
- Integers above 2^53 lose precision silently in JavaScript. JSON numbers are doubles. Send large ids as strings — this is why so many APIs do.
- If JSON.parse succeeded, the data is valid.
- It's syntactically valid. Nothing about the shape, types or ranges has been checked. Parsing and validation are separate concerns. In TypeScript this is worse than it looks, because parse returns any and a type annotation silences the compiler without checking anything.
When not to use it
- You're sending binary data — images, files, buffers.
- A binary format, or multipart upload. Base64 in JSON inflates size by about a third and costs CPU on both ends.
- High-volume internal service traffic where payload size and parse speed dominate.
- Protocol Buffers or MessagePack. JSON's readability stops being worth its cost when no human reads it.
- You need comments, trailing commas, or references between parts of the document.
- JSON5 or YAML for config files. Strict JSON deliberately has none of these.
Terms
- Serialization
- — Converting an in-memory value into a transmittable format.
- Deserialization
- — Reconstructing a value from that format. The step where type fidelity is usually lost.
- Reviver
- — A function passed to JSON.parse that transforms values as they're read — the standard hook for restoring dates.
- toJSON
- — A method stringify calls on an object if present, letting a class control its own serialised form.
- IEEE-754 double
- — The floating-point representation behind JavaScript numbers. The reason integers above 2^53 are unsafe.
- Schema validation
- — Checking that parsed data has the expected shape and types. Separate from, and necessary after, parsing.
In an interview
What happens to a Date when you send it through a JSON API?
- stringify calls toJSON, producing an ISO 8601 string
- parse returns it as a string, not a Date
- restoring it requires a reviver or explicit conversion
- JSON has no date type — this is a format limitation, not a bug
Why do APIs return large ids as strings?
- JSON numbers are doubles, so integers above 2^53 lose precision
- the corruption is silent — no error is raised
- sending as a string preserves the exact value
- it passes tests with small ids and fails in production with real ones
Can you recall it?
Name two things that silently go wrong when data round-trips through JSON, and why they happen.