How to auto-generate a typescript zod schema from excel column data
- Step 1Export your first sheet's rows to JSON — The generator takes JSON, not
.xlsx. Use SheetJSXLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]), or copy one representative row object. A single object yields a per-row schema; an array of objects infers from the first element. - Step 2Open the JSON to Zod generator — The Excel-to-Zod card redirects to /tool/json-to-zod. Paste your JSON sample into the input — nothing is uploaded; conversion happens in the browser.
- Step 3Set the root name —
rootName(defaultschema) becomes the exportedconstand, capitalised, the inferred type. For aCustomersheet, setcustomerso you getconst customerandtype Customer. - Step 4Choose strict vs loose objects —
strictObjects: false(default) emitsz.object(), which strips unknown keys on parse. TogglestrictObjects: trueforz.strictObject(), which rejects any column not in your sample. - Step 5Pick export and indent style — Leave
exportAll: trueto prefix theconstandtypewithexport. Setindentto 2 or 4 spaces to match your project's formatter. - Step 6Copy the schema and add your own refinements — Copy the generated
z.object()andz.infer<>alias into your project. The generator gives you structure only — add.optional(),.email(),.min(), or date refinements yourself for the columns that need them.
What the JSON-to-Zod generator infers from a sample value
Inference is structural and reads one sample value per field (the first array element when you paste an array). Source: lib/json-to-zod.ts inferZod().
| Sample value (from your sheet cell) | Generated Zod | Notes |
|---|---|---|
"Acme Ltd" (text) | z.string() | Every non-numeric, non-boolean cell becomes a string |
42 (whole number) | z.number().int() | Integers get .int() automatically |
19.99 (decimal) | z.number() | Non-integer numbers stay plain z.number() |
true / false | z.boolean() | Only real JSON booleans; the text "true" infers as z.string() |
null / blank exported as null | z.null() | No .optional() is added — see the optional-fields edge case |
"2026-06-12" (date text) | z.string() | No date detection; add .datetime() / .date() yourself |
["a", "b"] | z.array(z.string()) | Homogeneous arrays infer the element type |
[1, "x"] | z.array(z.union([z.number().int(), z.string()])) | Mixed arrays use z.union |
[] (empty) | z.array(z.unknown()) | No elements to infer from |
{ "a": 1 } (nested) | z.object({ a: z.number().int() }) | Nested objects recurse |
The four real options at /tool/json-to-zod
These are the only controls. There is no per-Excel options panel — the Excel-to-Zod card redirects here. Source: lib/json-to-zod.ts JsonToZodOptions.
| Option | Default | Effect |
|---|---|---|
rootName | schema | Names the exported const and (capitalised) the z.infer<> type |
exportAll | true | Prefixes the const and type with export |
strictObjects | false | false -> z.object() (strips unknown keys); true -> z.strictObject() (rejects them) |
indent | 2 | 2- or 4-space indentation in the emitted schema |
Two paths from Excel to a Zod schema on JAD
Pick by whether you can convert to JSON and how much Excel-native inference you want.
| Path | Input | Inference | Output |
|---|---|---|---|
| JSON to Zod (/tool/json-to-zod) | A JSON sample of one sheet | Single sample value per field; no .optional(), no dates | z.object() + z.infer<> alias |
| tRPC Router Builder | .xlsx / .csv first sheet | Samples rows; number/string/boolean; every field .optional() | Zod schema + TS interface + CRUD procedures |
Cookbook
Real first-sheet samples converted to JSON, then run through the JSON to Zod generator. Values anonymised. Each shows the JSON in and the exact Zod out, including where you must hand-edit.
A single product row to a typed schema
Convert the first sheet's header+row into one JSON object, paste it, set rootName to product. Integers get .int(), decimals stay plain.
Sheet row -> JSON:
{ "sku": "AC-1001", "name": "Widget", "qty": 50, "price": 19.99, "active": true }
rootName: product
Generated:
import { z } from "zod";
export const product = z.object({
sku: z.string(),
name: z.string(),
qty: z.number().int(),
price: z.number(),
active: z.boolean()
});
export type Product = z.infer<typeof product>;Date column stays a string — add the refinement yourself
The generator has no date detection. An ISO date cell becomes z.string(); you tighten it with .datetime() or .date() after pasting.
JSON:
{ "id": 7, "created": "2026-06-12" }
Generated (verbatim):
export const schema = z.object({
id: z.number().int(),
created: z.string()
});
Hand-edit for real validation:
created: z.string().date() // or .datetime() for timestampsstrictObjects rejects stray columns
Turn on strictObjects when your sheet must have exactly these columns and any extra header is an error.
JSON: { "email": "a@x.com", "plan": "pro" }
strictObjects: true
Generated:
export const schema = z.strictObject({
email: z.string(),
plan: z.string()
});
z.strictObject rejects { email, plan, legacyId } with an
'unrecognized key' error — exactly what you want for a fixed template.Array of rows infers from the FIRST element
sheet_to_json yields an array. The generator infers the array schema from element zero, so make sure row one is fully populated.
JSON (sheet_to_json output):
[
{ "name": "Sue", "score": 88 },
{ "name": "Jon", "score": 91 }
]
Generated:
export const schema = z.array(z.object({
name: z.string(),
score: z.number().int()
}));
If row one had score blank/null, score would infer z.null() —
put a complete row first or paste a single representative object.Adding optionals the generator won't
Optional columns are your call. Generate the structure, then wrap the columns your sheet leaves blank in some rows.
JSON: { "name": "Sue", "phone": "555-0100", "notes": null }
Generated:
export const schema = z.object({
name: z.string(),
phone: z.string(),
notes: z.null()
});
Hand-edit so blank cells pass:
phone: z.string().optional(),
notes: z.string().nullable().optional()Edge cases and what actually happens
Uploading an .xlsx to the Zod tool
Not acceptedThe redirect target /tool/json-to-zod accepts .json, .ndjson, .jsonl, and .txt — not .xlsx or .csv. Convert the first sheet to JSON first (SheetJS sheet_to_json) or use the Excel-native tRPC Router Builder for direct .xlsx input.
Expecting auto `.optional()` on sometimes-blank columns
By designThe generator never adds .optional(). It reads one sample value: if that cell is populated you get z.string(), if it is null you get z.null(). There is no multi-row scan. Add .optional() / .nullable() yourself for columns that vary, or use the tRPC Router Builder, which wraps every field in .optional() after sampling rows.
Date columns inferred as plain strings
Expected"2026-06-12" is just text to the inference engine, so it becomes z.string() with no .datetime() or .date(). This is documented behaviour, not a bug — append the refinement after pasting.
Numeric-looking text exported with quotes
Preserved as stringIf your JSON export quotes an order number ("00042"), the generator sees a string and emits z.string() — correct, because the leading zero matters. If you exported it as a bare number 42, you get z.number().int(). Control this at the Excel-to-JSON step, not in the schema.
Array first element has a null cell
Inference driftInference reads element zero of an array. If row one's score is blank/null, the whole array element infers score: z.null(). Put a fully populated row first, or paste a single representative object instead of the array.
Invalid JSON pasted
Parse errorconvertJsonToZod calls JSON.parse on the trimmed input — a trailing comma, single quotes, or an unquoted key throws a SyntaxError and no schema is produced. Re-export valid JSON (SheetJS output is always valid) and retry.
Column header with spaces or symbols
Quoted keyKeys that are not valid JS identifiers are emitted quoted, e.g. "Order Total": z.number(). The schema still compiles, but you may prefer to sanitise headers first with the header sanitizer before exporting to JSON.
Empty array column
SupportedA column whose sample value is [] becomes z.array(z.unknown()) — the schema compiles but accepts any element type. Provide a non-empty sample array if you want the element type inferred.
Very large pasted JSON above the free limit
413 limitJSON to Zod is a Pro tool; the free file cap is 2 MB. You only need one row or one object to infer a schema, so trim your sample — never paste the whole workbook's JSON.
Expecting a downloadable .schema.ts file from Excel
Copy outputEarlier drafts of this page implied a .schema.ts download from an Excel options panel. The real flow outputs TypeScript text you copy from the JSON to Zod generator; save it to a .ts file yourself.
Frequently asked questions
Does the Excel-to-Zod tool read my .xlsx directly?
No. On JAD the Excel-to-Zod card and /excel-tools/excel-to-zod redirect to /tool/json-to-zod, which infers from a JSON sample. Convert your first sheet to JSON (SheetJS sheet_to_json) first, or use the Excel-native tRPC Router Builder for direct .xlsx/.csv input.
How does it choose between z.number().int() and z.number()?
Integer values get z.number().int(); values with a fractional part get plain z.number(). This is decided per sample value in inferZod, so an integer-looking decimal like 19.0 exported as 19 infers .int().
Why are my date columns z.string()?
There is no date detection — any string, including "2026-06-12", infers z.string(). Add .date() or .datetime() to those fields after pasting. The tRPC Router Builder also treats dates as strings.
Can I make extra spreadsheet columns an error?
Yes. Set strictObjects: true to emit z.strictObject(), which rejects any key not present in your sample. The default z.object() silently strips unknown keys instead.
How do I name the schema and type?
Set rootName (default schema). You get export const <rootName> and export type <Capitalised rootName> = z.infer<typeof <rootName>>. For a customer sheet, use customer to get const customer and type Customer.
Does it add .optional() for blank cells?
No. It reads one sample value: populated -> z.string(), null -> z.null(), with no .optional(). Add .optional()/.nullable() yourself, or use the tRPC Router Builder, which makes every field .optional().
What if my sample is an array of rows?
It infers z.array(...) from the first element, so put a fully populated row first. For a per-row schema you can later wrap in z.array(), paste a single object instead.
Is anything uploaded?
No. The generator runs entirely in your browser (convertJsonToZod in lib/json-to-zod.ts). Only an anonymous run counter is recorded for signed-in dashboard stats; cell values and PII never leave your machine.
What are the size limits?
JSON to Zod is a Pro tool with a 2 MB free file cap. You only need one representative object or row to infer a schema, so the practical limit is irrelevant — keep the sample small.
Can I get a TypeScript interface too?
JSON to Zod gives you a z.infer<typeof schema> type alias, which is usually enough. If you want a separate interface plus Zod, the tRPC Router Builder emits both from your Excel headers, or convert to TS via the JSON to TypeScript tool.
Does it handle nested objects from merged cells?
It handles nested JSON objects (recursing into z.object()), but Excel merged cells do not produce nested JSON on export — sheet_to_json flattens to one level. Nest your JSON manually if you need nested schemas.
What's the difference from converting to SQL or markdown?
Same redirect pattern: Excel-to-SQL goes to /tool/json-to-sql and Excel-to-Markdown to /tool/json-to-markdown. All three take a JSON sample. For a code-generation alternative that reads Excel directly, see Python dict generator.
Privacy first
Every JAD Excel tool runs entirely in your browser using SheetJS and ExcelJS. Your spreadsheets, formulas, and data never leave your device — verified by zero outbound network requests during processing.