Safely Parsing JSON in JavaScript
JSON.parse looks simple but has sharp edges — prototype pollution, BigInt, untrusted input. A practical checklist for parsing JSON in production JS.
JSON.parse(text) is one line of code, which makes it tempting to treat
JSON parsing as solved. It isn't — not when the input comes from the
network, the filesystem, or a user. This guide is a practical checklist
for parsing JSON safely in JavaScript and TypeScript: what JSON.parse
throws, how to type the result without lying, and the prototype-pollution
trap that has bitten several real codebases.
JSON.parse basics
JSON.parse(text, reviver?) takes a string and returns a value, or throws
a SyntaxError. It does not return null or undefined on
failure — it throws. Any production call site must be prepared for that.
const value = JSON.parse('{"a":1}'); // { a: 1 }
JSON.parse('{"a":'); // SyntaxError: Unexpected end of JSON input
The thrown error has message, name, and (in modern V8 and SpiderMonkey)
a cause property when constructed by some hosts. The message has the
parser's best guess at the position — see
common JSON syntax errors for
decoding it.
Always wrap in try/catch
Untrusted input means parse failures. A single uncaught throw will crash a request handler or unmount a React tree.
function safeParse(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
Two things to log on failure:
- The error message and position.
- A truncated prefix of the input (say 200 chars) — never the full body, which may contain secrets and may be megabytes.
if (!result.ok) {
console.warn("JSON parse failed", {
error: result.error,
preview: text.slice(0, 200),
length: text.length,
});
}
The reviver function
JSON.parse accepts a second argument: a function called for every key/value
pair during parsing. It can transform values — typically to rehydrate
dates or BigInts.
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/;
function reviver(key, value) {
if (typeof value === "string" && ISO_DATE.test(value)) {
return new Date(value);
}
return value;
}
const data = JSON.parse('{"createdAt":"2026-05-13T10:00:00Z"}', reviver);
data.createdAt instanceof Date; // true
For BigInt, you need a sentinel because JSON numbers are already
double-precision floats by the time the reviver sees them — you lose
precision before the reviver runs. Two workarounds:
- Serialize large integers as strings, then
BigInt()them in the reviver. - Use a library like
json-bigintthat hooks the parser earlier.
The prototype-pollution trap
This is the one most developers don't know about. The JSON spec allows
"__proto__" as an object key, and JSON.parse will faithfully reproduce
it — but if you then merge the parsed object into another object,
some merge implementations walk the prototype chain and pollute
Object.prototype for the entire process.
const evil = JSON.parse('{"__proto__":{"polluted":true}}');
// evil itself is fine — but...
const target = {};
naiveMerge(target, evil);
console.log({}.polluted); // true — every object now has this property
Defenses:
- Use
Object.create(null)for maps that hold untrusted keys. - Use a merge library that rejects
__proto__,constructor, andprototypekeys (Lodash 4.17.11+,defu, your own). - If you only need to read fields, never merge, you're safe.
The same caveat applies to using parsed objects as keys into a Map
or as property names — assigning target[parsed.key] = value with an
attacker-controlled key is the bug.
Schema-validate untrusted input
JSON.parse proves the input is syntactically valid JSON. It does
not prove the input has the shape your code expects. A two-step pipeline
— parse, then validate against a schema — is the safe pattern.
With Zod:
import { z } from "zod";
const User = z.object({
id: z.string(),
email: z.string().email(),
age: z.number().int().nonnegative(),
});
function parseUser(text: string) {
const json = JSON.parse(text); // throws on syntax error
return User.parse(json); // throws on shape error
}
For server endpoints with hostile inputs, also enforce a size limit
before parsing — a 100 MB blob of [[[[[… is a valid prefix that will
exhaust memory long before it errors out. Reject anything above the
expected envelope size.
See the JSON Validator to validate ad hoc payloads against a schema, and the JSON to TypeScript tool to generate the type declarations from a sample.
Streaming and large payloads
JSON.parse is a one-shot, all-in-memory parser. For payloads that don't
fit comfortably in memory:
- NDJSON — one JSON record per line. Stream line-by-line;
JSON.parseeach line. This is what cloud loggers emit. - Streaming parsers —
clarinet,stream-json, or browserTextDecoderStream+ a state machine. Suitable when the document is a single huge array and you want records as they arrive.
See working with large JSON files for the deep dive.
Typing the result without lying
JSON.parse returns any in TypeScript. The temptation is to cast:
const user = JSON.parse(text) as User; // a lie — the compiler can't check
This produces type errors at runtime. The honest pattern is parse to
unknown, then narrow with a validator:
const raw: unknown = JSON.parse(text);
const user = User.parse(raw); // zod throws if shape is wrong; result is typed
If you control both ends and trust the source (your own backend, your own config file), the cast is pragmatic. For everything else, validate.
Checklist
Before shipping JSON parsing to production:
- Wrap in try/catch.
- Log error + truncated preview, not the full body.
- Enforce a size limit on the input.
- Validate the shape (Zod, Yup, ajv, io-ts) — don't trust the cast.
- Avoid
__proto__-aware merges on parsed objects. - For dates / BigInts, use a reviver or serialize as strings.
- Use a streaming parser if the payload can exceed a few MB.
Tooling:
- JSON Validator — syntax + schema check.
- JSON to TypeScript — generate types from a sample.
Next steps
- Common JSON syntax errors — what
JSON.parsewill throw at you. - JSON best practices for REST APIs — design the payloads you parse.