Skip to content
Chapter 9Lesson 1

JSON at the wire boundary

How JavaScript values cross the wire as JSON, and the discipline that treats every parsed value as unknown until a schema proves its shape.

Every SaaS sends and receives JSON at four sites. A Server Action receives a JSON body. A route handler returns one. A third-party webhook POSTs one in. localStorage round-trips one through the browser. These are four different transports, each with its own chapter later in the course, but every one of them sits on the same line of code:

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

A junior reads that and moves on. An experienced engineer reads it and asks one question: what is the type of data? The honest answer is unknown, whatever JSON.parse’s TypeScript signature claims, and that answer splits the work into two steps. The first step parses the string into a JavaScript value whose shape you don’t yet know. The second narrows that value to a domain type with a schema. Skip the second step and every malformed webhook, every missing field, every string where a number was expected rides silently through to your database.

This chapter closes Unit 1 by installing three disciplines at the boundary between in-memory values and the outside world. This lesson covers the JSON wire, the next covers when to reach for class, and the lesson after closes with the Date to Temporal pivot. All three answer the same trust question: what is the value’s shape after it has crossed?

Every JSON crossing has the same shape: a string on the wire , a JavaScript value in memory, and a codec (JSON.parse and JSON.stringify) sitting between them. Different mechanisms move the bytes, but the codec is the same regardless. Here are the four sites the course will eventually teach.

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.

The four directions don’t share a mechanism. Server Actions and route handlers are framework constructs, webhooks come in over HTTP, and localStorage lives in the browser. What they do share is a discipline: the value that comes out of JSON.parse is unknown until a schema narrows it. The next section installs that discipline once, and after that every wire site in the course inherits it.

JSON.parse(s) returns the JavaScript value the JSON string described. The TypeScript signature claims that value is any, but at the boundary any is a false promise. The runtime hands back whatever the wire sent, and any lets your code read fields off it without complaint. The safer habit is to treat the result as unknown and refuse to touch a field until a schema has narrowed the shape.

That gives you two steps. The two snippets below show the same parse done the wrong way and the right way, so read them side by side.

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

JSON.parse returns any by convention. The compiler lets you read data.invoiceId and data.amount without complaint, and the runtime hands back whatever the wire sent. A malformed webhook, a missing field, or a string where a number was expected: none of it is caught until something deeper in the stack breaks, such as a Postgres type error, a charge for NaN, or a tenant ID that turned out to be null. The mistake to recognize here is reading the response and trusting its shape.

The discipline in one sentence: the wire is unknown until validated. Every wire boundary in the codebase wraps JSON.parse in a helper whose return type is unknown, so the only path forward is the schema. There is no escape hatch for reading a field off the raw parse “just this once,” and removing that escape hatch is what makes the discipline hold.

One throw is worth knowing about. JSON.parse throws SyntaxError when the string isn’t valid JSON. You don’t catch that at every call site; you catch it once at the transport layer, in the route handler wrapper or the Server Action wrapper, and the same wrapper renders the user-visible 400. An empty string and a missing body throw too, so guard before parsing only when the source might legitimately give you nothing.

You’ll often see the fetch API’s body methods used instead of JSON.parse(await req.text()). Request.json() and Response.json() call JSON.parse under the hood and return Promise<any>, so the same “treat it as unknown” rule applies. Pair every .json() with a Zod parse in the same expression: const body = invoiceWebhookSchema.parse(await req.json()). A later chapter on fetch and live streams covers the route-handler mechanics; the shape of the seam is already here.

JSON’s grammar carries six value types: object, array, string, number, boolean, and null. That’s the whole alphabet. JavaScript carries richer values than that, including undefined, Date, BigInt, NaN, Infinity, Map, Set, Symbol, functions, and class instances. When JSON.stringify meets a value the grammar can’t carry, it either drops the value or coerces it to the nearest thing the grammar can express. Four of those compromises come up often enough that you should know them by heart.

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

Object properties with undefined values are dropped from the output. Array elements become null. This difference between objects and arrays is where the trouble starts: { a: undefined } round-trips to {}, so a receiver that checks 'a' in obj gets a different answer than it expected. The habit worth forming is to use null when you mean “explicitly absent in JSON” and undefined when you mean “not set in TS,” and to let Zod’s schema enforce which fields can carry each.

Those are the four to memorize as a set. A few more silent drops are worth a sentence each. Functions, Symbol keys, and Symbol values are dropped from objects entirely, and functions become null in arrays. Map and Set serialize as {}, an empty object rather than their entries. Cyclic references throw TypeError: cyclic object value, so when you need a deep clone reach for structuredClone (from the earlier chapter on the JavaScript value model) rather than JSON.parse(JSON.stringify(...)). Class instances serialize their own enumerable properties only, so methods, getters, and #private fields disappear; the next lesson of this chapter covers when a toJSON() method earns its weight.

JSON.parse(s, reviver) and JSON.stringify(value, replacer, space) both accept a second-argument hook that walks the value bottom-up. Both are narrow tools that belong at the boundary, never in domain code. For type reconstruction, prefer Zod’s .transform() over a reviver: the schema owns the contract, and a reviver that lives apart from it will drift out of sync. Two specific cases still earn each hook its weight.

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 include __proto__ and constructor keys. An unguarded Object.assign({}, parsed) into a target object then mutates the prototype chain, so every object in the process inherits whatever the attacker injected. The reviver strips those keys before any consumer sees the parsed value, because returning undefined from the reviver removes the property entirely. In 2025 this attack vector hit several major SDKs in the wild, and defensive parsing is the cheap fix. The next section’s parseJson helper wraps this reviver once, so every call site in the codebase is protected and no one has to remember to add it.

The second case 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 walks every key in the payload and substitutes redacted strings before serialization. This is the right tool when logging untrusted payloads, because the redaction lives at the log site, in one place, rather than scattered across the codebase as ad-hoc delete payload.password lines. A later chapter on structured logging centralizes redaction at the logger itself; what you see above is the inline form for projects that aren’t there yet.

Everything else is type reconstruction, and that contract belongs on the schema: parsing an ISO string back to Temporal.Instant, a number-as-string back to BigInt, or a date-string back to a PlainDate. The later chapter on Zod covers the z.iso.datetime().transform((s) => Temporal.Instant.from(s)) shape. Using a reviver to do the same job duplicates the contract in two places that will eventually drift apart.

The two-step seam, parse to unknown then narrow with Zod, lives once in lib/json.ts. Every wire boundary in the codebase imports it, and no caller writes JSON.parse and a schema parse separately. The annotations below walk through it one piece at a time.

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, so __proto__ and constructor keys are stripped before the schema ever sees the value. No call site can forget, because no call site writes its own JSON.parse. The cost is two field comparisons per key, and the benefit is that one class of attack is structurally impossible everywhere parseJson is the entry point.

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 that JSON.parse’s signature would otherwise pass through to the caller. If the string isn’t valid JSON, this line throws SyntaxError, and the caller (the route handler wrapper or the Server Action wrapper) catches that at the transport layer, not here. Domain code calls parseJson and 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 if the parsed value doesn’t match, and the same transport-layer catch handles it. The return type is z.infer<Schema>, derived from whichever schema the caller passed in, so a parseJson(raw, invoiceWebhookSchema) call returns the validated Invoice shape end to end. The later chapter on Zod covers safeParse for the recoverable form, where the caller wants the validation error as a Result instead of a throw. This seam takes the throw because route handlers and Server Action wrappers are the boundary that owns the response.

1 / 1

Every wire site reaches for this same helper. A route handler receiving a webhook calls const body = parseJson(await req.text(), webhookSchema). A localStorage read on the client calls const draft = parseJson(localStorage.getItem('draft') ?? '{}', draftSchema). The Server Action site wraps FormData differently (a later chapter on Server Actions covers it), but everywhere a JSON string crosses into the process, the seam is one import. No call site reimplements the discipline.

The later chapter on Zod covers the schemas that compose with this seam: z.iso.datetime(), z.coerce.number(), .transform() for type reconstruction, and .safeParse() for recoverable validation. This lesson installs the seam; the Zod chapter covers what flows through it.

A handful of small traps didn’t fit anywhere else in the lesson, so they’re collected here.

  • JSON.parse(JSON.stringify(x)) is not deep-clone. It misses Date, Map, Set, Symbol, undefined, BigInt, and cycles. Reach for structuredClone(x) (from the earlier chapter on the JavaScript value model) instead.
  • Catching SyntaxError at every call site is noise. The transport-layer wrapper, the route handler or Server Action wrapper, owns it. Domain code calls parseJson and lets the throw bubble.
  • JSON.stringify(value, null, 2) pretty-prints, but use it only in dev. Production responses and log payloads use no space argument, because pretty-printed JSON inflates payload size 1.5–2× for no production benefit.
  • Don’t use JSON.stringify as a content-hash input. Key order matches insertion order in V8, but it isn’t specified, so different runtimes can disagree and the same logical value can produce different hashes. Reach for fast-json-stable-stringify or a sorted-keys canonicalizer when you need the hash to be stable.
  • JSON is fast enough. Beginners often ask whether the verbosity means they should use MessagePack or Protobuf instead. In 2026 SaaS, JSON over HTTP/3 with brotli compression is the default. The alternatives win only at the profiler level, not at the level this lesson works on, so they aren’t worth the complexity here.

Two exercises follow. The first cements the four serialization holes that the rest of the lesson builds on. The second checks the discipline: do you spot the unsafe parse when you read it?

For each snippet, type the exact string JSON.stringify returns. The holes from the lesson are all in here, and one snippet adds the Map-to-empty-object silent drop for good measure.

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') }));

There are three files below. Each contains exactly one defect at the JSON wire boundary, 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 });
};

Every JSON crossing has the same shape: a string on the wire, an unknown after the parse, and a typed domain value after the schema. The wire is unknown until validated. When you read a parse, ask what shape the value has, and answer the same way every time: unknown, until a schema narrows it. The parseJson helper makes that discipline a one-import seam, with the prototype-pollution defense built in for free.

The next lesson turns to the receiving side. Once a schema has reconstructed the domain value, what does that value look like? Mostly records and functions, but there are exactly three places where class still earns its weight in a 2026 SaaS codebase. The lesson after closes the chapter with the Date to Temporal pivot, which is one of the type reconstructions you’ll wire through parseJson once the Zod chapter lands.