Skip to content

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-bigint that 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, and prototype keys (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.parse each line. This is what cloud loggers emit.
  • Streaming parsersclarinet, stream-json, or browser TextDecoderStream + 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:

Next steps