obfus.link
Generators

JSON to Zod with branded types, Auto-JSDoc, and date strategy

Convert any JSON payload into a production-grade Zod schema with optional branded ID types, JSDoc generation, and configurable date handling — replace a 400-line hand-written schema with one tool call.

JSON-to-Zod produces a production-grade Zod schema from any JSON object or array in a single tool call. With optional branded ID types that prevent UserId being passed where OrgId is expected, an Auto-JSDoc typedef block documenting every field, configurable date strategy, and a strict-mode toggle. Replaces hand-written schemas for partner APIs, webhook payloads, and LLM-output validation.

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 @typedef JSDoc 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 as z.string().datetime(). Validates ISO 8601 format but does not parse. Use when the consuming code expects strings.
  • 'coerce'z.coerce.date(). Parses anything new Date() accepts (ISO, RFC 2822, epoch milliseconds). Use when you want a Date and don't care about strict validation.
  • 'pipe'z.string().datetime().pipe(z.coerce.date()). Validates the string first (rejects non-ISO), then parses to a Date. 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_zod once 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

json

string | object

The JSON to analyse. String inputs are parsed first.

schemaName

string

The exported constant name (e.g. UserSchema).

strict

boolean

false

Append .strict() to object schemas. Rejects unknown keys at parse time.

coerce

boolean

false

Use z.coerce.* for primitives. Useful when upstream returns stringly-typed numbers.

autoJsDoc

boolean

false

Emit a @typedef JSDoc block above the schema with one @property per field.

inferBranding

boolean

false

Apply z.brand() to fields whose keys match identity patterns (id, *Id, uuid, *Token, *Hash, *Key).

dateStrategy

'string' | 'coerce' | 'pipe'

'pipe'

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 id

Parent key + Id (user.idUserId)

*Id, *_id

PascalCase the key (userIdUserId, org_idOrgId)

*Uuid, *_uuid

Same, with Uuid suffix

*Token

XToken

*Hash

XHash

*Key

XKey

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

INPUT_EMPTY

json is empty

Provide a non-empty value

INPUT_MALFORMED

String input failed JSON.parse

Pre-clean with llm_to_json_cleaner or fix the JSON

INPUT_TOO_LARGE

Input exceeds 500KB

Sample-down the payload to a representative subset

UNSUPPORTED_FORMAT

Top-level value is null or a primitive

Wrap in an object: {"value": yourPrimitive}

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.

Try it now

JSON to Zod

Generate Zod schemas with JSDoc and branded types from any JSON

FAQ

Frequently asked questions

How does branding work?

inferBranding detects fields whose keys match identity patterns (id, *Id, *Token, *Hash, *Key) and wraps them in z.brand<'TypeName'>(). At runtime these are still strings; at compile time they're mutually unassignable. Prevents the most common JSON-typing bug.

When should I use coerce mode?

When the upstream returns stringly-typed numbers ("amount": "14900") or boolean strings ("verified": "true"). z.coerce wraps every primitive in a permissive parser. Default false because production payloads should be properly typed; coerce is for working around legacy systems.

What's the difference between the three date strategies?

"string" keeps the field as z.string().datetime() (validates but doesn't parse). "coerce" uses z.coerce.date() (parses anything new Date() accepts). "pipe" combines both — validates the ISO string, then parses to Date. Pick based on whether your consuming code expects strings or Dates.

Does it handle nested objects?

Yes, recursively. Branded types are applied at every level. JSDoc is generated for every nested object. The output is one big schema with .strict() applied at every nested object level when strict: true.

How does it compare to ts-to-zod?

ts-to-zod converts TypeScript type definitions into Zod schemas. json-to-zod converts JSON values into Zod schemas. Different inputs, complementary tools — use ts-to-zod when you have TS types, json-to-zod when you have JSON samples.