1. Insight
Insight
The problem this article addresses and why it matters.
The schema gap between JSON in the wild and your TypeScript code
You receive a JSON payload from an upstream system you don't control — a webhook, a partner API, a database export, an LLM response. You want to use it in a TypeScript codebase that enforces strict null checks. Between those two facts sits the same chore every developer has done a hundred times: write the Zod schema by hand, type out every field, decide on .optional() vs .nullable(), remember to call .strict() if you want extra-key rejection, and add JSDoc comments so the next person to read the schema knows what each field means.
The chore is shallow. Each step is obvious in isolation. Stack them across 200 fields and a Tuesday afternoon and you have a 400-line schema that's identical to one a teammate wrote last quarter for the same upstream system. Maintenance is worse: the partner API adds a field, you discover the omission via a runtime parse failure six weeks later, and you patch the schema by hand again.
Why off-the-shelf converters fall short
Several open-source json-to-zod converters exist on npm. They handle the basic case. The features they don't handle — and the reason every team eventually writes their own version — are the ones that compound across a real codebase: branded ID types (so a userId from one schema can't be passed where an orgId is expected), Auto-JSDoc generation from key names, configurable date-string handling (z.string().datetime() vs z.coerce.date() vs the .pipe(z.coerce.date()) chain), and the choice between .strict() and .passthrough() on object schemas.
The converter in this article handles all four, plus the version-skew issue: when the upstream JSON shape changes in production, you re-run the converter against the new shape and get a diff against your previous schema rather than starting over.
What this article delivers
End-to-end walkthrough of converting a real JSON payload with Auto-JSDoc, branded ID extraction, and a chosen date strategy. We cover the three configuration knobs (strict, coerce, inferBranding), the round-trip pattern for keeping a schema fresh as upstream payloads evolve, and the cases where the converter's output needs human refinement before shipping.
2. Intent
Intent
What you will be able to do after reading.
By the end of this article you will be able to:
- Convert any JSON object or array into a production-grade Zod schema in a single tool call
- Read the generated
@typedefJSDoc block and understand which fields are documented vs inferred - Apply branded ID types automatically to fields whose keys signal identity (
userId,orgId,apiKey,*Token,*Hash) - Choose between the three Zod date strategies (
string().datetime(),coerce.date(),.pipe(coerce.date())) based on whether your upstream emits ISO strings, RFC 2822 strings, or epoch milliseconds - Re-run the converter as upstream JSON evolves and diff the generated schema against your committed version
The Examples section walks through a real partner-webhook payload and produces a schema you could drop into a TypeScript project unmodified.
3. Examples
Examples
Annotated code and worked scenarios.
Before / after: a partner webhook payload
A Stripe-style webhook payload arrives from a partner system:
Before — the raw JSON:
{
"id": "evt_3Rn0qH2eZvKYlo2C0a1b2c3d",
"event_type": "invoice.paid",
"created": "2026-05-15T19:24:33.000Z",
"data": {
"object": {
"id": "in_1Rn0pZ2eZvKYlo2C0p1q2r3s",
"customer": "cus_QyZ4t8eZvKYlo2C",
"amount": 14900,
"currency": "usd",
"paid": true
}
}
}A naive hand-written Zod schema for that would take 10-20 lines and leave the id fields as plain z.string(). Then a developer six months later passes an event id where a customer id is expected and TypeScript catches nothing.
After — generated:
jsonToZod({
json: rawWebhookPayload,
schemaName: 'StripeInvoicePaidEvent',
strict: true,
coerce: false,
autoJsDoc: true,
inferBranding: true,
dateStrategy: 'pipe',
});Output (schema field):
import { z } from 'zod';
/**
* @typedef {object} StripeInvoicePaidEvent
* @property {EventId} id - event identifier (branded)
* @property {string} event_type - event type
* @property {Date} created - created (parsed from ISO 8601)
* @property {object} data
*/
export const StripeInvoicePaidEvent = z.object({
id: z.string().brand<'EventId'>(),
event_type: z.string(),
created: z.string().datetime().pipe(z.coerce.date()),
data: z.object({
object: z.object({
id: z.string().brand<'InvoiceId'>(),
customer: z.string().brand<'CustomerId'>(),
amount: z.number(),
currency: z.string(),
paid: z.boolean(),
}).strict(),
}).strict(),
}).strict();
export type StripeInvoicePaidEvent = z.infer<typeof StripeInvoicePaidEvent>;The id, customer, and inner id fields each get a distinct brand derived from the surrounding key name. Now EventId and CustomerId are mutually unassignable at compile time — the most common JSON-typing bug closes off as a category.
Before / after: choosing a date strategy
The dateStrategy parameter takes one of three values:
'string'— keeps the field asz.string().datetime(). Validates ISO 8601 format but does not parse. Use when the consuming code expects strings.'coerce'—z.coerce.date(). Parses anythingnew Date()accepts (ISO, RFC 2822, epoch milliseconds). Use when you want aDateand don't care about strict validation.'pipe'—z.string().datetime().pipe(z.coerce.date()). Validates the string first (rejects non-ISO), then parses to aDate. Use when you want both validation and parsing.
// Same input, different dateStrategy:
jsonToZod({ json, schemaName: 'X', dateStrategy: 'string' }).schema;
// created: z.string().datetime(),
jsonToZod({ json, schemaName: 'X', dateStrategy: 'coerce' }).schema;
// created: z.coerce.date(),
jsonToZod({ json, schemaName: 'X', dateStrategy: 'pipe' }).schema;
// created: z.string().datetime().pipe(z.coerce.date()),When humans use this
A developer integrating a new upstream API runs the partner's first sample payload through the converter and pastes the result into their codebase. The Auto-JSDoc reduces "what is this field?" questions during code review — every field is documented automatically, with the doc text derived from the key name (user_id becomes "user id (branded)" via simple snake_case to prose conversion). When the upstream API ships v2 and adds three fields, the developer re-runs the converter against a v2 sample and pastes the new schema in. The diff against the prior commit is the changelog.
When agents use this
The converter is high-value in any agent pipeline that bridges JSON sources into a typed downstream. Two patterns:
- Bootstrap-time schema generation. An agent ingesting a new data source at runtime calls
json_to_zodonce against the first sample payload, caches the generated schema, then validates all subsequent payloads against it. The cached schema becomes the contract — anything that fails validation triggers a re-conversion call to detect a payload shape change. - Code generation. An agent writing TypeScript scaffolding for a partner integration uses the converter to produce the schema and the JSDoc — both go into the generated codebase together. The branded types prevent the most common downstream typing errors without the LLM having to "understand" branding.
Edge cases
Empty arrays
An empty array ([]) in the input produces z.array(z.unknown()) because the converter has no element to infer from. Re-run with a non-empty sample to get a strongly-typed element schema. The output marks these with a @todo refine element JSDoc note.
Mixed-type arrays
Heterogeneous arrays ([1, "two", true]) produce z.array(z.union([z.number(), z.string(), z.boolean()])). The converter does not try to detect a discriminated union — that requires semantic knowledge the input doesn't expose. If you have a tagged-union pattern, refine the output by hand.
Recursive structures
JSON technically can't represent cyclic references, but tree-shaped data with self-similar nesting (a comment with children comments) produces z.lazy(() => CommentSchema). The converter detects depth via repeated keys at successive levels.
Null-vs-optional
{"a": null} and a key being absent are different things. The converter generates z.null() for explicit nulls and .optional() for missing-from-some-samples (when given an array of objects). The output JSDoc marks each.
4. Documentation
Documentation
Reference signatures, edge cases, and lookup tables.
Input parameters
Field | Type | Required | Default | Description |
|---|---|---|---|---|
|
| ✓ | — | The JSON to analyse. String inputs are parsed first. |
|
| ✓ | — | The exported constant name (e.g. |
|
| ✗ |
| Append |
|
| ✗ |
| Use |
|
| ✗ |
| Emit a |
|
| ✗ |
| Apply |
|
| ✗ |
| How to handle ISO 8601 date string fields. See Examples section. |
Output shape
{
schema: string; // Zod schema source, including the export statement
imports: string; // `import { z } from 'zod';`
jsdoc?: string; // @typedef block when autoJsDoc: true
types?: string; // z.infer<...> export statement
}Branding heuristic
inferBranding: true matches a field key against this set (case-insensitive):
Pattern | Brand name derivation |
|---|---|
Exact | Parent key + |
| PascalCase the key ( |
| Same, with |
|
|
|
|
|
|
Brands are nominal types — EventId and CustomerId are both z.string() at runtime but mutually unassignable at compile time. See the Zod docs on .brand() for the implementation details.
Error codes
Code | When it fires | Recovery |
|---|---|---|
|
| Provide a non-empty value |
| String input failed | Pre-clean with |
| Input exceeds 500KB | Sample-down the payload to a representative subset |
| Top-level value is | Wrap in an object: |
When NOT to use this tool
If the upstream provides a JSON Schema document (OpenAPI spec, JSON Schema file), use json-schema-to-zod from npm instead — it operates on the schema definition, not a sample payload, and produces a more accurate result for unions and oneOf constructs. This tool is for the case where you have examples but no spec.
If the source is a TypeScript type definition rather than a JSON payload, use ts-to-zod from npm. The converter expects plain JSON values, not type definitions.
Performance notes
Typical execution: under 5ms for inputs below 50KB. The branding heuristic adds 1-2ms regardless of input size. The converter is deterministic — same input + same parameters always produce byte-identical output — so REST responses are Edge-Cache eligible. Memory usage is bounded by the parsed input depth; deeply nested JSON (more than 50 levels) returns INPUT_MALFORMED rather than risk stack overflow on the consumer.