How to generate zod-ready typescript interfaces from json
- Step 1Capture a representative JSON value — Use a complete, populated sample — an API response or fixture that includes every field. Sparse samples generate an incomplete interface, and the generator won't infer keys that aren't present or union across array elements.
- Step 2Generate the TypeScript interface — Paste the JSON, set a 'Root type name' like
User, and click 'Generate Types'. Review the interface: nested objects appear as separate named interfaces, which is exactly where nestedz.object({})wrappers go. - Step 3Transcribe each field to Zod — Walk the interface top to bottom:
string→z.string(),number→z.number(),boolean→z.boolean(), nested interface→z.object({ ... }),string[]→z.array(z.string()),(number | string)[]→z.array(z.union([z.number(), z.string()])). - Step 4Add the constraints the type can't express — Layer on validation the interface can't carry:
z.string().email(),z.string().uuid(),z.number().min(0).max(100),z.enum(['active','inactive']),z.string().datetime(). These constraints are the entire point of Zod over a bare type. - Step 5Handle null and optional fields deliberately — Where the interface shows
field: null, decide the real type and usez.string().nullable()(or the right base). Where a field is sometimes absent, add.optional()— the generator can't detect this, so apply per-field judgement. - Step 6Derive the type with z.infer — Once the schema is written, derive the static type from it:
type User = z.infer<typeof UserSchema>. Delete the hand-generated interface so the schema is the single source of truth and the two can never drift.
TypeScript interface to Zod, field by field
The generated interface maps almost mechanically onto Zod. The right column is what you write by hand from the generated type — or what the sibling json-to-zod tool emits directly.
| Generated TS | Zod equivalent | Note |
|---|---|---|
field: string | field: z.string() | Add .email(), .uuid(), .url(), .datetime() as needed |
field: number | field: z.number() | This tool can't distinguish int from float; json-to-zod emits z.number().int() for integers |
field: boolean | field: z.boolean() | Direct mapping |
field: null | field: z.null() | Usually you want z.string().nullable() — widen the base type by hand |
nested interface Foo | field: z.object({ ... }) | Each named interface becomes a nested z.object |
field: string[] | field: z.array(z.string()) | Element type carries through |
field: (number | string)[] | z.array(z.union([z.number(), z.string()])) | Mixed-array unions map to z.union |
field?: string (optional ON) | field: z.string().optional() | The global optional switch mirrors where .optional() goes |
How JSON values map to TypeScript
The mapping is driven entirely by the JavaScript runtime type of each parsed value — there is no schema, no format detection, and no heuristics beyond these rules.
| JSON value | Generated TS type | Detail |
|---|---|---|
"text" | string | Every string is string — no string-literal union, no email/uuid/date-string narrowing |
42 or 9.5 | number | Integers and floats both become number. (The sibling json-to-zod does emit .int() for integers — TypeScript has no integer type, so this tool can't) |
true / false | boolean | No literal true/false narrowing |
null | null | A null value produces the literal type null — not string | null. There is no nullable inference |
{ ... } | named interface/type | Named from the key (or the root name). Emitted as a separate top-level type and referenced by name |
[ ] (empty) | unknown[] | An empty array can't be sampled, so the element type is unknown |
[1, 2, 3] | number[] | Element type inferred from the array contents |
[1, "a"] | (number | string)[] | Mixed-type arrays produce a union element type |
[{...}, {...}] | <Name>Item[] | Object arrays produce one item interface — sampled from the first element only |
Free vs Pro generation limits
JSON to TypeScript is a Pro tool gated by the shared codeGeneration quota (lib/preview-quotas.ts). Limits are daily-run counts plus a row cap that applies when the JSON root is an array.
| Tier | Runs/day | Array row cap | File size |
|---|---|---|---|
| Free | 1 | 100 rows | 2 MB |
| Pro | 5 | 1,000 rows | 100 MB |
| Pro Media | 50 | 10,000 rows | 100 MB |
| Developer / Enterprise | Unlimited | Unlimited | 100 MB+ |
Cookbook
JSON in, the generated interface, and the Zod schema you transcribe from it — plus where the sibling json-to-zod tool short-circuits the work.
Flat object to interface to schema
ExampleThe base case: generate the interface, then transcribe each field to its Zod equivalent and add constraints.
Input:
{ "id": 1, "email": "a@x.com", "active": true }
Generated interface:
export interface User {
id: number;
email: string;
active: boolean;
}
Transcribed Zod (with constraints added):
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
active: z.boolean(),
});Nested interface to nested z.object
ExampleA nested object becomes a named interface, which maps to a nested z.object wrapper in the same position.
Input:
{ "name": "Ada", "address": { "city": "London", "zip": "EC1" } }
Generated:
export interface Root {
name: string;
address: Address;
}
export interface Address { city: string; zip: string; }
Zod:
const Schema = z.object({
name: z.string(),
address: z.object({ city: z.string(), zip: z.string() }),
});null field becomes z.null() — widen to nullable
ExampleA null sample value generates the literal null; in Zod you almost always want .nullable() on the real base type.
Input:
{ "name": "Ada", "deletedAt": null }
Generated (verbatim):
export interface Root {
name: string;
deletedAt: null;
}
Zod (widened by hand):
const Schema = z.object({
name: z.string(),
deletedAt: z.string().datetime().nullable(),
});Optional switch mirrors .optional()
ExampleFlipping 'Optional properties' on marks every field ?, which corresponds to chaining .optional() — but apply it per field, not globally, in the real schema.
Generated (Optional properties: ON):
export interface Root {
id?: number;
nickname?: string;
}
Zod (transcribe selectively — id is required, nickname optional):
const Schema = z.object({
id: z.number(),
nickname: z.string().optional(),
});Skip the transcription with json-to-zod
ExampleIf you want a runnable schema rather than a blueprint, the sibling tool emits the z.object() directly — including z.number().int() for integers and z.infer at the bottom.
Input:
{ "id": 1, "name": "Ada" }
json-to-zod output (runnable, verbatim shape):
import { z } from "zod";
export const schema = z.object({
id: z.number().int(),
name: z.string()
});
export type Schema = z.infer<typeof schema>;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.
null becomes z.null(), rarely what you want
ExpectedA null sample value maps to the literal null (and naively to z.null()). The real intent is almost always z.string().nullable() or similar — widen the base type by hand. Capture a sample with the field populated to infer the underlying type instead.
Constraints can't be inferred
By designEmail format, min/max ranges, regex, UUID, and enum membership aren't present in a JSON sample, so the generated interface (and any naive Zod transcription) lacks them. Adding these constraints is the entire reason to use Zod — layer them on by hand: .email(), .min(0), .uuid(), z.enum([...]).
Optional is global, not per-field
By designThe optional switch marks every field ? at once and never detects which fields are sometimes-absent. When transcribing to Zod, apply .optional() per field based on your real contract, not by mirroring the global switch.
Array sampled from first element
By designAn array of objects infers its item interface from the first element; later-only keys are missing from both the type and your transcribed z.array(z.object({...})). Reorder the sample so the richest element is first, or merge a superset object.
Enum-like string typed as string
ExpectedA status: "active" value becomes string, naively z.string(). Replace with z.enum(['active','inactive','pending']) to actually validate membership — the sample shows one value, not the allowed set.
Integer vs float not distinguished
Tool differenceThis tool types both 42 and 9.5 as number (TypeScript has no integer type). If you want z.number().int() for integers without transcribing by hand, use the sibling json-to-zod, which inspects each number and emits .int() for integers.
Map-like object becomes fixed keys
ExpectedA dictionary { "en": "...", "fr": "..." } is typed with literal keys. In Zod, a dynamic map should be z.record(z.string(), z.string()) instead of a fixed z.object. Recognize maps and transcribe them as z.record.
Duplicate nested type names collide
First write winsTwo different nested objects sharing a key name yield a single interface (first write wins), so your transcribed schema would reuse one shape for both. Rename one key before generating, or generate the branches separately.
Empty array
unknown[]An empty array becomes unknown[], which has no element type to transcribe. Decide the element type and write z.array(z.string()) (or the right type), or capture a sample where the array is populated.
Invalid JSON pasted
Parse errorThe input runs through JSON.parse after trimming; malformed JSON throws and the error surfaces in the UI. Repair with json-format-fixer first, then regenerate the interface.
Frequently asked questions
Should I write the interface first or the Zod schema first?
In a settled codebase, write the Zod schema and derive the type with z.infer<typeof schema> — that guarantees the runtime validator and the static type never drift. This tool is the on-ramp when you're starting from raw JSON and don't yet know the shape: generate the interface to see the structure, transcribe it to a schema, then switch to z.infer and delete the hand-generated interface.
Can it generate the Zod schema directly, or just the interface?
This tool generates the TypeScript interface only — it's a blueprint you transcribe. For a runnable schema with no transcription, use the sibling json-to-zod: it emits the import { z }, the z.object({...}), and a z.infer type alias in one shot, and it even emits z.number().int() for integer values, which this tool can't represent in plain TypeScript.
How do I handle JSON null values in Zod?
A null in the sample produces field: null here, which naively maps to z.null() — but that only accepts null. You almost always want .nullable() on the real base type: z.string().nullable() for string-or-null, or z.string().nullable().optional() for optional-and-nullable. Decide the underlying type and chain .nullable(); the generator can't know the non-null type from a null sample.
How do I add email, UUID, and range validation?
By hand — these aren't in a JSON sample so neither the interface nor a naive transcription carries them. After mapping field: string to z.string(), chain the constraint: z.string().email(), z.string().uuid(), z.string().datetime(), or z.number().min(0).max(100). These refinements are the whole reason to use Zod over a bare type, so expect to add them in every schema.
An enum field came out as `string` — how do I validate the allowed values?
The sample shows one value ("active"), which the generator types as string. Replace the transcribed z.string() with z.enum(['active','inactive','pending']) to validate membership at runtime. You supply the full allowed set; no tool can infer it from a single observed value.
My JSON is an array of objects with varying keys — why are some fields missing from the type?
When the root (or any nested value) is an array of objects, the generator infers the item interface from the first element only. Keys that appear in later elements but not the first are absent from the generated type; keys in the first element but not later ones are present and non-optional. Reorder your sample so the most complete object is first, or merge a representative superset object by hand before pasting. This is the single most common surprise — the tool samples, it does not union across array elements.
A dictionary/map object got fixed keys — what's the Zod equivalent?
The generator types { en: '...', fr: '...' } with literal keys, but if the keys are open-ended you want z.record(z.string(), z.string()) rather than a fixed z.object. Recognize map-shaped objects (locale codes, dynamic IDs) and transcribe them as z.record, not a key-by-key object schema.
Why does the integer-vs-float distinction matter, and which tool keeps it?
TypeScript has only number, so this tool types both 42 and 9.5 as number and can't express .int(). Zod can validate integers with z.number().int(). If that distinction matters to you, the sibling json-to-zod inspects each numeric value and emits .int() for integers automatically — saving you from re-checking each number during transcription.
Two different nested objects share a key name — why did one shape get lost?
Nested object types are named from their key, and the generator registers each name only once (first write wins). If a meta object appears in two places with different shapes, both references point to the single Meta interface built from whichever was walked first; the second shape is silently discarded. Rename one of the JSON keys before generating, or generate the two branches separately and rename the resulting interfaces. The collision is structural, not a bug you can toggle off.
Can I stop the tool from exporting every type?
Yes — uncheck 'Export all types' and the export prefix is dropped from every emitted type. That's useful when you want a single barrel file to control your public surface, or when the generated types are module-private helpers. The toggle is all-or-nothing; there's no per-type export control, so for mixed visibility, generate with export off and add export to the specific types you want public.
What are the size and run limits?
JSON to TypeScript is a Pro tool gated by the shared codeGeneration quota. Free is 1 run per day; if the JSON root is an array, only the first 100 rows are sampled before generation. Pro is 5 runs/day with a 1,000-row sample cap; Pro Media is 50/day at 10,000 rows; Developer and Enterprise are unlimited. The file-size ceiling is 2 MB on free and 100 MB on paid tiers. The row cap only matters for array roots — a single deep object generates the same types regardless of tier because the shape, not the row count, drives the output.
Is my JSON data uploaded to a server?
No. Generation runs entirely in your browser via the converter in lib/json-to-typescript.ts — the JSON is parsed and walked client-side, and only an anonymous run counter is recorded when you're signed in. JSON data never leaves the tab, which is why you can safely paste real responses that contain customer records, tokens, or internal identifiers.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.