How to generate zod schemas for safe runtime json parsing
- Step 1Capture a representative response — Save a real response from the API, webhook, or file you parse. Pick one where every field is present and non-null if possible — inference only sees keys that exist, and a null value narrows the type to
z.null(). - Step 2Drop the JSON onto the tool — Drag a
.jsonfile onto the dropzone above. A spreadsheet (.xlsx,.xls,.ods) is read as its first sheet into a JSON array of row objects, useful when the data originates from a sheet export. - Step 3Name the schema after the source — Set Root schema name to e.g.
weatherResponse. The export becomesexport const weatherResponse = z.object({...})with typeWeatherResponse. - Step 4Choose Strict objects to detect new fields — Tick Strict objects if you want
z.strictObjectto reject responses that gain unexpected fields — a way to be notified when an upstream API adds keys. Leave it off (defaultz.object) to tolerate extra fields silently. - Step 5Generate, then copy or download — Click Generate Schema and Copy or Download .ts. The module begins with
import { z } from "zod";. - Step 6Parse safely and add nullability/format — Replace the unsafe call:
const data = weatherResponse.parse(JSON.parse(text))(orsafeParsefor graceful errors). Refine for reality: fields that can be null →.nullable(), ISO timestamps →z.string().datetime(), sometimes-absent fields →.optional().
JSON.parse vs Zod parse
Why schema-validated parsing beats raw JSON.parse for any data you don't control.
| Concern | JSON.parse() | schema.parse(JSON.parse()) |
|---|---|---|
| Return type | any | fully typed via z.infer |
| Missing field | silent undefined | throws / typed error |
| Wrong leaf type | silent (string vs number) | rejected at the boundary |
| Upstream shape drift | undetected | detected immediately |
| Extra fields | kept, untyped | kept (z.object) or rejected (z.strictObject) |
| Error handling | try/catch parse only | safeParse → typed error.issues |
Inferred type vs what you usually refine
Structural output and the runtime-safety constraint you add for external data.
| Sample value | Generated | Refine to |
|---|---|---|
"2026-06-12T00:00:00Z" | z.string() | .datetime() |
"https://x.com" | z.string() | .url() |
42 | z.number().int() | keep or .int() |
null | z.null() | z.string().nullable() |
{ "next": null } | z.object({ next: z.null() }) | next: z.string().nullable() |
[] | z.array(z.unknown()) | real element schema |
Cookbook
Sample external payloads, the schema produced, and the safe-parse call you replace JSON.parse with.
Replace an unsafe fetch().json()
Examplefetch().json() returns any. Pipe it through the generated schema to get a validated, typed value.
Sample response:
{ "id": 7, "name": "Acme", "active": true }
Generated:
import { z } from "zod";
export const orgResponse = z.object({
id: z.number().int(),
name: z.string(),
active: z.boolean()
});
export type OrgResponse = z.infer<typeof orgResponse>;
Usage:
const raw = await (await fetch('/api/org')).json(); // any
const org = orgResponse.parse(raw); // OrgResponsesafeParse for graceful degradation
ExampleUse safeParse to handle shape drift without crashing — log the issue and fall back.
const result = orgResponse.safeParse(raw);
if (!result.success) {
console.error('API drift:', result.error.issues);
return fallbackOrg; // don't crash the UI
}
const org = result.data; // typed OrgResponseValidate a localStorage blob on read
ExamplePersisted state can be stale from an old app version. Parse it through a schema before trusting it.
Sample stored object:
{ "theme": "dark", "sidebar": { "open": true, "width": 240 } }
Generated:
export const prefs = z.object({
theme: z.string(),
sidebar: z.object({ open: z.boolean(), width: z.number().int() })
});
const stored = localStorage.getItem('prefs');
const parsed = stored ? prefs.safeParse(JSON.parse(stored)) : null;
const prefsData = parsed?.success ? parsed.data : DEFAULT_PREFS;Detect when an upstream API adds fields
ExampleWith Strict objects on, a newly-added upstream field fails parsing — your alerting catches the drift instead of it going unnoticed.
Strict objects: ON
export const resp = z.strictObject({
id: z.number().int(),
name: z.string()
});
// Upstream later returns { id, name, beta: true }
resp.safeParse(data).success // false → you get alerted
// (z.object would silently ignore 'beta')Nullable and optional from real-world data
ExampleExternal data has nulls and missing keys. Generate the skeleton, then mark reality: nullable for null-able fields, optional for sometimes-absent.
Generated (sample had a non-null avatar):
export const user = z.object({
name: z.string(),
avatar: z.string()
});
Reality: avatar can be null and bio can be absent. Refine:
export const user = z.object({
name: z.string(),
avatar: z.string().nullable(),
bio: z.string().optional()
});Errors and edge cases
Real errors and silent failures sourced from each platform's own documentation. Match the wording to the row, fix what the row says to fix.
ISO date string parses as z.string()
By designA timestamp value like "2026-06-12T00:00:00Z" is just a string at runtime, so it becomes z.string(). Add .datetime() (or .date(), .url(), .email()) at the boundary. Inference never guesses string formats.
Field that can be null was non-null in the sample
Type too narrowIf your sample happened to have a value, the schema gets the concrete type and will reject the real null later. Conversely a null sample yields z.null(), rejecting real values. Mark such fields .nullable() explicitly — a single sample can't tell you which fields are nullable.
Optional field absent from the sample
Missing keyInference only includes keys present in your sample, so an optional field that wasn't returned this time is absent from the schema and a later response containing it stays untyped (or, with Strict on, gets rejected). Add .optional() for fields that may be absent.
Array of heterogeneous objects
Object unionIf a list contains objects of different shapes, the generator emits z.array(z.union([z.object({...}), z.object({...})])) rather than one merged schema. For an items array that should be uniform, sample a uniform slice; for genuinely polymorphic lists, convert to a discriminated union by hand.
Mixed-type array
Union elementAn array like [1, null, "x"] becomes z.array(z.union([z.number().int(), z.null(), z.string()])). This is accurate, but if the upstream API should return one type, the union is a red flag worth tightening.
Empty array yields z.array(z.unknown())
Unknown elementWhen the sample array is empty, the element type can't be inferred, so you get z.array(z.unknown()). Capture a response with at least one element, or replace z.unknown() with the real element schema, otherwise validation accepts anything in that array.
Strict object rejects a legitimately-new field
By designWith Strict on, z.strictObject rejects any field not in your schema — including a benign new field the upstream API added. That is the intended drift-detection behaviour; loosen to z.object or add the field once you've reviewed it.
Numeric ID exceeds safe integer range
Precision riskJSON.parse reads very large integers (e.g. 64-bit IDs) as floats, losing precision before Zod ever sees them — and the generator would infer z.number(). For big IDs, ensure the API sends them as strings and model them as z.string(); the generator can't recover precision already lost by JSON.parse.
Frequently asked questions
Why is replacing JSON.parse with Zod safer?
JSON.parse returns any, so TypeScript can't catch a missing field or a wrong type — the bug surfaces deep in your code. schema.parse(JSON.parse(text)) validates the runtime shape and returns a typed value, so drift is caught at the boundary with a clear error instead of silently corrupting downstream logic.
How do I parse without throwing?
Use safeParse: it returns { success: true, data } or { success: false, error }. On failure, inspect error.issues for the exact mismatch, log it, and fall back to a default — your app degrades gracefully instead of crashing on unexpected data.
Does the generator add .datetime() to timestamp strings?
No. A timestamp is a string value, so it becomes z.string(). Add .datetime(), .date(), .url(), or .email() yourself. The generator produces structure only and never guesses string formats.
How do I handle fields that are sometimes null?
Mark them .nullable(). A single sample can't reveal nullability — if the value was present, you get the concrete type; if it was null, you get z.null(). Review fields that you know can be null and add .nullable() (or .nullish() for null-or-absent).
How do I detect when an upstream API changes shape?
Turn on Strict objects so z.strictObject rejects responses with unexpected new fields, and pair it with safeParse so a failure triggers your logging/alerting. With plain z.object, extra fields are silently ignored, so drift goes unnoticed.
What about very large integer IDs?
JSON.parse reads integers beyond Number.MAX_SAFE_INTEGER as imprecise floats before Zod runs, so precision is already lost. Have the API send large IDs as strings and model them as z.string(). The generator can't recover precision that JSON.parse discarded.
How do I get a TypeScript type from the parsed data?
Use the exported z.infer<typeof schema> alias — it's the type of schema.parse(...)'s result. Rename the root to match the source (e.g. weatherResponse → WeatherResponse) and use that type wherever the parsed data flows.
Are my sample responses uploaded?
No. Generation runs entirely in your browser; sample responses — even ones with real data — never reach JAD Apps. Strip any secrets from the sample first; only the shape is needed.
What if the array in my response is empty in the sample?
You'll get z.array(z.unknown()), which accepts anything. Provide a sample response with at least one element so the element type is inferred, or replace z.unknown() with the correct element schema by hand.
Can it produce a discriminated union for polymorphic responses?
No. The generator unions observed object shapes but can't identify a discriminator. For a list of { type: 'a' | 'b', ... } variants, build z.discriminatedUnion('type', [...]) manually once you know the cases.
How do I validate config files or localStorage blobs?
Same pattern: schema.safeParse(JSON.parse(text)). Persisted data can be stale from an older app version, so validating on read (then falling back to defaults) prevents stale-shape bugs. Generate the schema from a known-good sample of the file/blob.
I only need the static type, not runtime checks — what then?
If you genuinely trust the source and only want a TypeScript shape, use json-to-typescript. But the whole point of runtime parsing is that you don't trust external data — for that, the Zod schema this tool generates is the right choice. For a JSON Schema document, see json-schema-generator.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.