How to generate a zod schema for react form validation
- Step 1Capture a real form submission as JSON — Log one valid submission from
onSubmit, or hand-write an object whose keys are your fieldnameattributes:{ "email": "user@example.com", "age": 25, "acceptTerms": true, "address": { "city": "New York", "zip": "10001" } }. Use realistic values — the generator infers types from values, so"25"(quoted) producesz.string()while25producesz.number().int(). - Step 2Drop the JSON file onto the tool — Drag a
.jsonfile onto the dropzone above. A spreadsheet works too: an.xlsx,.xls, or.odsis read as its first sheet and converted to a JSON array of row objects before inference. - Step 3Set the root schema name — Type a name in the Root schema name box, e.g.
signupForm. That becomesexport const signupForm = z.object({...})and the type is the capitalised form,SignupForm. Leave it asschemaif you do not care. - Step 4Choose Export schemas and Strict objects — Leave Export schemas on so the
constandtypeare exported (default). Tick Strict objects if you wantz.strictObject(...)instead ofz.object(...)so any extra field in the submission is rejected rather than silently passed through. - Step 5Generate and copy the schema — Click Generate Schema. Copy the result, or Download .ts to save it as
<name>.schema.ts. The output starts withimport { z } from "zod";. - Step 6Add the validators the generator can't infer — Layer on intent:
email: z.string().email('Invalid email'),age: z.number().int().min(18, 'Must be 18+'),zip: z.string().regex(/^\d{5}$/),acceptTerms: z.literal(true). Then wire it up:useForm({ resolver: zodResolver(SignupForm) }).
JSON value to Zod type mapping
Exactly what the generator emits for each JSON leaf. Inference is purely structural — it reads the value's runtime type, never its meaning.
| JSON value in your sample | Generated Zod | What you typically add for a form |
|---|---|---|
"user@example.com" | z.string() | .email('Invalid email') |
25 (whole number) | z.number().int() | .min(18).max(120) |
19.99 (decimal) | z.number() | .positive() / .multipleOf(0.01) |
true / false | z.boolean() | z.literal(true) for a required checkbox |
null | z.null() | usually rewrite to z.string().nullable() |
{ "city": "NYC" } | z.object({ city: z.string() }) | constrain each nested field |
["a", "b"] | z.array(z.string()) | .min(1) for required multi-select |
[] (empty array) | z.array(z.unknown()) | replace z.unknown() with the real element type |
Options exposed in the UI
The three controls shown above the Generate button, with their effect on the emitted module.
| Control | Default | Effect on output |
|---|---|---|
| Root schema name | schema | Names the exported const and (capitalised) the type alias |
| Export schemas | On | Prefixes the const and type with export; off keeps them module-private |
| Strict objects | Off | Switches every z.object to z.strictObject so unknown keys are rejected |
Cookbook
Real form payloads and the schema the generator produces — plus the line you add to turn a skeleton into a real validator.
A flat signup form
ExampleThe most common case: email, password, age, and a terms checkbox. Note the generator never adds .email() or .min() — that is your job in the second step.
Input (sample submission):
{ "email": "a@b.com", "password": "hunter2", "age": 31, "acceptTerms": true }
Generated:
import { z } from "zod";
export const schema = z.object({
email: z.string(),
password: z.string(),
age: z.number().int(),
acceptTerms: z.boolean()
});
export type Schema = z.infer<typeof schema>;
You then refine:
email: z.string().email(),
password: z.string().min(8),
acceptTerms: z.literal(true)Nested address block
ExampleDot-path form fields like address.zip come from a nested object in the payload and become a nested z.object. The shape mirrors your field structure one-for-one.
Input:
{ "name": "Sam", "address": { "city": "NYC", "zip": "10001", "unit": 4 } }
Generated:
export const schema = z.object({
name: z.string(),
address: z.object({
city: z.string(),
zip: z.string(),
unit: z.number().int()
})
});Custom root name for a named form
ExampleSetting the Root schema name to checkoutForm produces a self-documenting export you can import by name across your form components.
Root schema name: checkoutForm
Generated:
export const checkoutForm = z.object({ /* ... */ });
export type CheckoutForm = z.infer<typeof checkoutForm>;
Usage:
const form = useForm<CheckoutForm>({
resolver: zodResolver(checkoutForm)
});Strict objects to reject stray fields
ExampleTick Strict objects when a form posts a fixed set of fields and you want Zod to reject anything extra (e.g. injected hidden inputs).
Strict objects: ON
Generated:
export const schema = z.strictObject({
email: z.string(),
acceptTerms: z.boolean()
});
z.strictObject rejects { email, acceptTerms, csrf } with an
'Unrecognized key(s)' error — z.object would silently allow it.Cross-field rules the generator can't see
ExamplePassword-confirmation and other multi-field rules are not derivable from a single payload. Add them with .refine() after generating the skeleton.
Generated skeleton:
export const schema = z.object({
password: z.string(),
confirmPassword: z.string()
});
You add:
export const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(d => d.password === d.confirmPassword, {
message: 'Passwords must match', path: ['confirmPassword']
});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.
Email field comes out as plain z.string()
By designThe generator infers from the runtime type of the value, so "user@example.com" is just a string. It cannot know you want .email(). This is expected: the tool builds the structural skeleton, and you add format validators. The same applies to UUIDs, URLs, and ISO dates — all z.string() until you refine.
Optional field absent from the sample
Missing keyInference only sees keys present in your sample object. A field the user left blank (and your form omitted) will not appear in the schema at all. Capture a submission with every field populated, or add the field manually as z.string().optional() afterwards.
Field value was null in the sample
Narrowed typeA null value yields z.null(), which only accepts null. For a nullable field that is sometimes a string, rewrite it to z.string().nullable() (or z.string().optional() if it can be absent). The generator has no way to know the non-null type from a single null.
Number arrived as a quoted string
String not number{ "age": "25" } produces z.string(), not z.number(). HTML form inputs submit strings, so if your raw payload has quoted numbers, either coerce in the form layer or use z.coerce.number() after generating. Decide deliberately — this is a common silent mismatch.
Empty array gives z.array(z.unknown())
Unknown elementIf a multi-select or tag field was empty ([]) in the sample, the element type is unknowable, so you get z.array(z.unknown()). Provide a sample with at least one element, or replace z.unknown() with the real element type by hand.
Field name contains a hyphen or space
PreservedA key like "first-name" is not a valid JS identifier, so the generator quotes it: "first-name": z.string(). The schema is valid; you access it as data['first-name']. Consider renaming the form field with json-key-renamer before generating if you prefer dot access.
Free tier daily cap reached
Quota reachedOn the free tier, code generation is limited to 1 run per day (resetting at UTC midnight) with a 100-row cap on array inputs. A single form object counts as one run regardless of field count. Pro raises this to 5/day, and Developer is unlimited.
Whole list of submissions pasted as an array
Array schemaIf you paste an array of submissions, you get z.array(z.object({...})), not the per-record object schema. For a single form, paste a single object. On limited tiers, arrays over the row cap are sliced to the first N records before inference.
Frequently asked questions
Does this add .email() to my email field automatically?
No. The generator infers structure only, so an email value becomes z.string(). Add .email('Invalid email address') yourself. The same is true for .min(), .max(), .regex(), and .url() — these encode validation intent that cannot be read from a sample value.
How do I plug the output into React Hook Form?
Import the generated schema and pass it to zodResolver: const form = useForm<z.infer<typeof FormSchema>>({ resolver: zodResolver(FormSchema) }). Use the exported z.infer type for useForm's generic so field names are typed. Errors then appear at form.formState.errors.<field>.
How do I make a field optional?
Add .optional() after generating: z.string().optional(). The generator marks every key it sees as required because it cannot tell whether absence is allowed. For fields that should validate only when filled, use z.union([z.string().email(), z.literal('')]) or z.string().email().optional().
How do I validate that two fields match, like password and confirm?
Use .refine() or .superRefine() on the object: schema.refine(d => d.password === d.confirmPassword, { message: 'Passwords must match', path: ['confirmPassword'] }). The path array controls which field shows the error in React Hook Form.
What does Strict objects do for a form?
It emits z.strictObject(...) instead of z.object(...), so any field in the submission that is not in the schema causes a validation error. This is useful for locking a form payload to its declared shape and catching injected or stale hidden inputs. Leave it off if your form posts extra metadata you do not want to validate.
Can I generate from a spreadsheet of test cases?
Yes. Drop an .xlsx, .xls, or .ods file and the first sheet is read as a JSON array of row objects, then the array schema is inferred. For a single form's shape, give one representative row; the result will be z.array(z.object({...})) from which you can lift the inner object.
Why is my numeric field a z.string()?
Because the value in your sample was a quoted string ("25"). HTML inputs submit strings, so this is common. Either coerce the value before validating (z.coerce.number()) or capture a payload where the number is already a real JSON number (25).
Is my form data uploaded anywhere?
No. Generation runs entirely in your browser. Field values, including emails and free text, are never transmitted to JAD Apps. You can confirm by generating with your network tab open — there is no request.
Does it support Formik as well as React Hook Form?
Yes. The output is a standard Zod schema, so it works with Formik via toFormikValidationSchema (from zod-formik-adapter), with @hookform/resolvers/zod, or with any code path that calls schema.safeParse(values).
How do I get a TypeScript type for my form values?
The tool already exports it: export type Schema = z.infer<typeof schema>. Rename the root to match your form (e.g. signupForm → SignupForm) and use that type as the generic for useForm<SignupForm>().
I have deeply nested form sections — does it handle them?
Yes, to arbitrary depth. Each nested object becomes a nested z.object() (or z.strictObject() when Strict is on), preserving your field-name dot paths exactly. Indentation in the output is two spaces.
What if I need a different validation library?
This tool targets Zod specifically. If you want plain TypeScript interfaces instead, use json-to-typescript. For a JSON Schema document (which Ajv and many validators consume), use 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.