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?
One codec, four directions
Section titled “One codec, four directions”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.
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 produces unknown
Section titled “JSON.parse produces unknown”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.
const raw = await req.text();const parsed: unknown = JSON.parse(raw);const body = invoiceWebhookSchema.parse(parsed);return chargeInvoice(body.invoiceId, body.amount);Two steps. First parse to unknown. The explicit annotation refuses the any that leaks through TypeScript’s signature. Then narrow with a Zod schema. The body that reaches business logic is both typed and validated, because the schema checks its runtime shape and pins its static type at the same time. The later chapter on Zod covers how invoiceWebhookSchema is built; this lesson installs the seam where it plugs in.
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.
The four serialization holes
Section titled “The four serialization holes”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.
const wire = JSON.stringify({ createdAt: new Date() });// '{"createdAt":"2026-05-28T10:00:00.000Z"}'const back = JSON.parse(wire);typeof back.createdAt; // 'string' — not a Datestringify calls the value’s toJSON() method, which for Date returns an ISO 8601 string. parse returns that string, not a Date. The round-trip downgrades the type: it goes out as a Date and comes back as a string. The two functions aren’t symmetric here because the JSON grammar has no date type, so the receiver has to reconstruct the Date itself. The chapter’s closing lesson, on the Date to Temporal pivot, installs that codec: the wire stays ISO 8601, and the in-memory type is Temporal.Instant.
JSON.stringify({ id: 9_007_199_254_740_993n });// TypeError: Do not know how to serialize a BigIntstringify throws TypeError on a BigInt. There are two ways to handle it. The default is to model the value as a string at the schema boundary: every Stripe ID and every 64-bit Postgres id lives as a string in the domain type, with the schema validating the format. The value stays text the whole way through, and you never do arithmetic on it. The second way, reserved for when you genuinely need arithmetic on the value, is an explicit replacer that calls .toString() on bigints. The reviver and replacer section below shows the shape.
JSON.stringify({ ratio: NaN, max: Infinity });// '{"ratio":null,"max":null}'Non-finite numbers serialize as null. A typical case: an analytics field overflows to NaN during the math, serializes to null, and the receiver has no way to tell that null from a real one. The cure is to validate at the math site rather than at the serializer. A Zod .refine(Number.isFinite) on numeric fields rejects the bad value before it ever reaches the wire. If a NaN is reaching JSON.stringify, a guard is missing somewhere upstream.
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.
reviver and replacer, narrowly
Section titled “reviver and replacer, narrowly”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 parseJson helper
Section titled “The parseJson helper”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.
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.
Watch-outs
Section titled “Watch-outs”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 missesDate,Map,Set,Symbol,undefined,BigInt, and cycles. Reach forstructuredClone(x)(from the earlier chapter on the JavaScript value model) instead.- Catching
SyntaxErrorat every call site is noise. The transport-layer wrapper, the route handler or Server Action wrapper, owns it. Domain code callsparseJsonand lets the throw bubble. JSON.stringify(value, null, 2)pretty-prints, but use it only in dev. Production responses and log payloads use nospaceargument, because pretty-printed JSON inflates payload size 1.5–2× for no production benefit.- Don’t use
JSON.stringifyas 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 forfast-json-stable-stringifyor 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.
Practice
Section titled “Practice”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?
Exercise 1: What does stringify do?
Section titled “Exercise 1: What does stringify do?”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 }));undefined values are dropped from the output entirely. The receiver sees {"a":1,"c":3}, not {"a":1,"b":null,"c":3}.Predict what this program prints, then press Check.
console.log(JSON.stringify([1, undefined, 3]));undefined can’t be dropped (positions matter), so it’s coerced to null. The asymmetry between objects (drop) and arrays (null) is the hole.Predict what this program prints, then press Check.
console.log(JSON.stringify({ ratio: NaN }));NaN, Infinity, or -Infinity. All three serialize as null — and the receiver has no way to tell them apart from a real null.Predict what this program prints, then press Check.
console.log(JSON.stringify(new Map([['a', 1]])));Map serializes as {} — JSON.stringify writes only own enumerable properties, and Map stores its entries in internal slots, not as properties. Reach for Object.fromEntries(map) or [...map] first if you want to ship the contents.Predict what this program prints, then press Check.
console.log(JSON.stringify({ createdAt: new Date('2026-05-28T00:00:00Z') }));stringify calls the value’s toJSON() method; Date.prototype.toJSON returns an ISO 8601 string. The round-trip downgrades the type — JSON.parse would hand back the string, not a Date.Exercise 2: Spot the unsafe parse
Section titled “Exercise 2: Spot the unsafe parse”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.
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 });};const archived = JSON.parse(JSON.stringify(invoice));archived.createdAt.getFullYear();const payload = { id: 9_007_199_254_740_993n, status: 'paid' };const body = JSON.stringify(payload);Wrap the parse with parseJson(raw, webhookSchema). The wire is unknown until validated — reading data.invoiceId and data.amount off a raw parse lets a malformed webhook poison the database with whatever the wire sent. Every field read here is unchecked.
JSON.parse(JSON.stringify(...)) is not deep-clone. It downgrades Date to a string (which is why getFullYear() blows up on the next line), drops undefined, drops methods, and throws on cycles. Use structuredClone(invoice) — the platform’s structured-clone algorithm preserves Date, Map, Set, BigInt, and typed arrays.
JSON.stringify throws TypeError on a bigint. The senior reach is to model Stripe IDs and 64-bit Postgres ids as strings in the domain type — the schema validates the format, the wire stays as text, no bigint ever reaches stringify. Fallback only when arithmetic is genuinely required: an explicit replacer that calls .toString() on bigints.
The three defects are the three traps this lesson named — trusting the parse, JSON-as-deep-clone, and BigInt at the serializer. The cure for all three lives at the seam: parse with a schema, clone with structuredClone, model 64-bit IDs as strings.
Wrapping up
Section titled “Wrapping up”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.
External resources
Section titled “External resources”The canonical reference for the parse signature, the reviver hook, and the SyntaxError behavior. Read it when you need the exact rules this lesson summarized.
web.dev's walkthrough of the structured-clone algorithm: what it preserves (Dates, Maps, Sets, cycles), what it drops (functions, prototype chain), and why it beats the JSON round-trip.
Official docs for the schema library used at the parseJson seam. The later chapter on Zod owns the full surface; this page is the entry point if you want to read ahead.
Production-grade drop-in replacement with prototype-poisoning protection: the library form of the safe reviver shown in this lesson. Reach for it when the threat model is strict; otherwise the inline reviver is enough.