Skip to content
Chapter 9Lesson 1

JSON at the wire boundary

How values cross the wire as JSON, and why a parsed value stays unknown until a schema proves its shape.

Every web app sends and receives JSON at four sites: an incoming Server Action body, an outgoing route handler response, a webhook POSTed by a third party, and a localStorage round-trip in the browser. Each uses a different transport, but every one sits on the same line:

const data = JSON.parse(req.body);
return chargeInvoice(data.invoiceId, data.amount);

What is the type of data? Whatever JSON.parse’s signature claims, the honest answer is unknown, and that splits the work in two. First parse the string into a value whose shape you don’t yet know. Then narrow that value to a domain type with a schema. Skip the second step and every malformed webhook, missing field, and string-where-a-number-belongs rides silently through to your database.

This chapter closes Unit 1 with three disciplines at the boundary between in-memory values and the outside world: the JSON wire here, class next, and the Date-to-Temporal pivot last. Each answers the same question: what is a value’s shape after it has crossed?

Every JSON crossing has the same three parts: a string on the wire , a value in memory, and the codec (JSON.parse and JSON.stringify) between them. The four sites move their bytes by different transports but share that one codec, so they share one discipline: the value out of JSON.parse is unknown until a schema narrows it.

Server Action body
request comes in as JSON
Webhook POST
third party sends JSON to your route handler
JSON.parse
JSON.stringify
one codec
Route handler response
response goes out as JSON
localStorage round-trip
browser-side persistence; string in, string out
Four wire sites, one codec. Whatever discipline you install at one site applies at every site.

JSON.parse(s) returns whatever the string described, typed as any by its TypeScript signature. At the boundary any is a false promise: the compiler lets your code read fields off the result while the runtime hands back whatever the wire sent. Treat the result as unknown instead, and touch no field until a schema has narrowed the shape.

const raw = await req.text();
const data = JSON.parse(raw);
return chargeInvoice(data.invoiceId, data.amount);

JSON.parse returns any. The compiler lets you read data.invoiceId and data.amount unchecked. A malformed webhook, a missing field, or a string where a number was expected goes uncaught until something deeper breaks: a Postgres type error, a charge for NaN, or a null tenant ID.

The discipline in one sentence: the wire is unknown until validated. Wrap JSON.parse in a helper whose return type is unknown, so the only path forward is the schema. There is no reading a field off the raw parse “just this once.”

JSON.parse throws SyntaxError on invalid JSON, and an empty or missing body throws too. Catch this once at the transport layer, in the route-handler or Server Action wrapper that also renders the 400, not at every call site.

You’ll often see fetch’s body methods in place of JSON.parse(await req.text()). Request.json() and Response.json() call JSON.parse under the hood and return Promise<any>, so the same rule applies: pair every .json() with a Zod parse in the same expression, const body = invoiceWebhookSchema.parse(await req.json()).

JSON’s grammar carries six value types: object, array, string, number, boolean, and null. JavaScript carries richer values too: undefined, Date, BigInt, NaN, Infinity, Map, Set, Symbol, functions, and class instances. When JSON.stringify meets one the grammar can’t carry, it either drops it or coerces it to the nearest thing the grammar can express. Four of those compromises bite often.

JSON.stringify({ a: 1, b: undefined }); // '{"a":1}'
JSON.stringify([1, undefined, 3]); // '[1,null,3]'

Object properties with undefined values are dropped; array elements become null. That asymmetry is the trap: { a: undefined } round-trips to {}, so a receiver checking 'a' in obj gets the wrong answer. Use null for “explicitly absent in JSON” and undefined for “not set in TS,” and let a schema enforce which fields carry each.

A few more values drop silently. Functions and Symbol keys or values are dropped from objects, and functions become null in arrays. Map and Set serialize as {}, not their entries. Cyclic references throw a TypeError, so deep-clone with structuredClone, not JSON.parse(JSON.stringify(...)). Class instances serialize their own enumerable properties only, so methods, getters, and #private fields disappear; the next lesson covers when a toJSON() method earns its weight.

JSON.parse(s, reviver) and JSON.stringify(value, replacer, space) each take a hook that walks the value bottom-up, visiting nested values before their parents. Both belong at the boundary, never in domain code. Two cases earn them.

The first is reviver as a defense against prototype pollution.

const safeReviver = (key: string, value: unknown): unknown => {
if (key === '__proto__' || key === 'constructor') return undefined;
return value;
};
const parsed: unknown = JSON.parse(rawJson, safeReviver);

Crafted JSON can carry __proto__ and constructor keys; an unguarded Object.assign({}, parsed) then mutates the prototype chain, and every object in the process inherits what the attacker injected. Returning undefined from the reviver deletes the property before any consumer sees the value. The next section’s parseJson helper wraps this reviver once, so every call site is protected by default.

The second is replacer for log-site field redaction.

const redact = (key: string, value: unknown): unknown =>
/password|token|secret|authorization/i.test(key) ? '[REDACTED]' : value;
logger.info('webhook received', JSON.stringify(payload, redact));

The replacer substitutes redacted strings during serialization, keeping the redaction in one place rather than scattered delete payload.password lines. A later chapter on structured logging moves this into the logger itself; the inline form above covers projects that aren’t there yet.

Everything else is type reconstruction, such as parsing an ISO string back to a Temporal.Instant, and that contract belongs on the schema, where Zod’s .transform() owns it. A reviver doing the same job duplicates the contract in a second place that drifts out of sync.

The two-step seam, parse to unknown then narrow with Zod, lives once in lib/json.ts.

import { z } from 'zod';
const safeReviver = (key: string, value: unknown): unknown => {
if (key === '__proto__' || key === 'constructor') return undefined;
return value;
};
export const parseJson = <Schema extends z.ZodType>(
raw: string,
schema: Schema,
): z.infer<Schema> => {
const parsed: unknown = JSON.parse(raw, safeReviver);
return schema.parse(parsed);
};

The prototype-pollution defense lives at the seam. Every parse runs through the same reviver that strips __proto__ and constructor, and no call site can forget it because no call site writes its own JSON.parse.

import { z } from 'zod';
const safeReviver = (key: string, value: unknown): unknown => {
if (key === '__proto__' || key === 'constructor') return undefined;
return value;
};
export const parseJson = <Schema extends z.ZodType>(
raw: string,
schema: Schema,
): z.infer<Schema> => {
const parsed: unknown = JSON.parse(raw, safeReviver);
return schema.parse(parsed);
};

Parse to unknown. The explicit : unknown annotation refuses the any from JSON.parse’s signature. An invalid string throws SyntaxError here, caught by the transport-layer wrapper; domain code lets the throw bubble.

import { z } from 'zod';
const safeReviver = (key: string, value: unknown): unknown => {
if (key === '__proto__' || key === 'constructor') return undefined;
return value;
};
export const parseJson = <Schema extends z.ZodType>(
raw: string,
schema: Schema,
): z.infer<Schema> => {
const parsed: unknown = JSON.parse(raw, safeReviver);
return schema.parse(parsed);
};

Narrow with the schema. schema.parse throws ZodError on a mismatch, caught by the same wrapper. The return type is z.infer<Schema>, so parseJson(raw, invoiceWebhookSchema) returns the validated Invoice shape. This seam takes the throw because route handlers and Server Action wrappers own the response; the Zod chapter covers safeParse for callers that want the error as a value instead.

1 / 1

Every wire site reaches for this helper. A route handler calls const body = parseJson(await req.text(), webhookSchema); a localStorage read calls const draft = parseJson(localStorage.getItem('draft') ?? '{}', draftSchema). The Server Action site wraps FormData differently, but everywhere a JSON string crosses into the process, the seam is one import.

  • JSON.parse(JSON.stringify(x)) is not deep-clone. It loses Date, Map, Set, Symbol, undefined, and BigInt, and throws on cycles. Use structuredClone(x).
  • Don’t catch SyntaxError at every call site. The transport wrapper owns it; domain code calls parseJson and lets the throw bubble.
  • JSON.stringify(value, null, 2) pretty-prints, but only in dev. Production responses and logs omit the space argument; the extra whitespace inflates payload size for no benefit.
  • Don’t feed JSON.stringify output to a content hash. Key order isn’t specified, so different runtimes can order the same value differently and hash it to different digests. Use fast-json-stable-stringify or a sorted-key canonicalizer when the hash must be stable.
  • JSON is fast enough. MessagePack and Protobuf win only at the profiler level, not worth the complexity here.

The first exercise drills the serialization holes; the second checks whether you spot an unsafe parse when you read it.

For each snippet, type the exact string JSON.stringify returns.

Predict what this program prints, then press Check.

console.log(JSON.stringify({ a: 1, b: undefined, c: 3 }));

Predict what this program prints, then press Check.

console.log(JSON.stringify([1, undefined, 3]));

Predict what this program prints, then press Check.

console.log(JSON.stringify({ ratio: NaN }));

Predict what this program prints, then press Check.

console.log(JSON.stringify(new Map([['a', 1]])));

Predict what this program prints, then press Check.

console.log(JSON.stringify({ createdAt: new Date('2026-05-28T00:00:00Z') }));

Each file below contains exactly one JSON-boundary defect, one of the three traps this lesson named: trusting the parse, a JSON round-trip used for deep-clone, or BigInt at the serializer. Click the offending line and leave the comment a senior reviewer would write.

Click the line carrying the JSON-boundary defect and leave the review comment a senior reviewer would write. Click any line to leave a review comment, then press Submit review.

app/api/stripe/webhook/route.ts
export const POST = async (req: NextRequest) => {
const raw = await req.text();
const data = JSON.parse(raw);
await chargeInvoice(data.invoiceId, data.amount);
return new Response(null, { status: 200 });
};

The wire is unknown until validated.

The next lesson covers the three places where class still earns its weight, and lesson 3 closes the chapter with the Date-to-Temporal pivot.