How to build a zod .input() validator for trpc procedures from excel data
- Step 1Pick your path — Need just the input schema fast? Use the JSON to Zod redirect. Want the whole router (schema + interface + procedures)? Use the tRPC Router Builder on the
.xlsxdirectly. - Step 2Convert one sheet row to JSON (quick path) —
XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]])and take a representative row object — that is the sample the generator infers from. - Step 3Generate the input schema — Paste the row at /tool/json-to-zod. Set
rootNametoimportInput. Copy thez.object()and itsz.infer<>alias. - Step 4Refine for the procedure — Add
.optional()to non-required fields,.email()/.min()where needed, andz.coerce.date()for date columns. The generator gives structure; tRPC validates exactly what you declare. - Step 5Wire into the router —
importRows: publicProcedure.input(z.array(importInput)).mutation(({ input }) => { /* input is typed ImportInput[] */ }). Invalid rows are rejected before your resolver runs. - Step 6Or generate the full router — On the tRPC Router Builder, set
routerName(defaultdataRouter) and copy the emitted router — schema, interface, and CRUD procedures included.
Two ways to get a tRPC Zod input from Excel
Quick schema vs full router. Sources: lib/json-to-zod.ts and lib/excel/excel-code-gen.ts.
| Path | Input | Output | Optional handling |
|---|---|---|---|
| JSON to Zod (/tool/json-to-zod) | One JSON row | z.object() + z.infer<> alias | You add .optional() |
| tRPC Router Builder | .xlsx/.csv sheet | Schema + interface + list/get/create/update/delete | Every field .optional() automatically |
Wiring the generated schema into tRPC
The generator does not write .input() boilerplate — you compose it. Standard for tRPC v10/v11.
| Goal | tRPC code |
|---|---|
| Validate a single record | .input(importInput) |
| Validate a batch import | .input(z.array(importInput)) |
| Paginated list query | .input(z.object({ page: z.number().int().min(1).default(1) })) |
| Type the resolver | ({ input }) => { /* input: z.infer<typeof importInput> */ } |
Generator options for tRPC inputs
json-to-zod options. The tRPC Router Builder instead exposes only routerName (default dataRouter).
| Option | Default | tRPC use |
|---|---|---|
rootName | schema | Name it importInput for a readable .input() |
strictObjects | false | true -> reject unexpected fields in the input payload |
exportAll | true | Keep export to import into the router file |
indent | 2 | Match project formatting |
Cookbook
From a spreadsheet row to a working tRPC procedure. Quick-path examples use the JSON to Zod redirect; the last shows the Excel-native full-router alternative. Data anonymised.
Single-record mutation input
Generate from one row, name it importInput, and use it directly in .input().
Sample row -> JSON: { "name": "Sue", "email": "sue@x.com", "seats": 5 }
rootName: importInput
Generated:
export const importInput = z.object({
name: z.string(),
email: z.string(),
seats: z.number().int()
});
Router:
addMember: publicProcedure
.input(importInput)
.mutation(({ input }) => db.member.create({ data: input }))Batch import with z.array()
Wrap the row schema in z.array() so the whole uploaded sheet is validated as one mutation input.
import { importInput } from "./schema";
importRows: publicProcedure
.input(z.array(importInput))
.mutation(async ({ input }) => {
// input is typed importInput[]; any bad row already rejected
await db.member.createMany({ data: input });
return { inserted: input.length };
})Add .email() and .optional() the generator won't
Structure comes from the generator; the semantic refinements are yours.
Generated: email: z.string(), nickname: z.string(), Refined for the procedure: email: z.string().email(), nickname: z.string().optional(), Now .input() rejects bad emails and accepts a missing nickname.
Strict input to reject smuggled fields
strictObjects makes an unexpected payload key a validation error before the resolver runs.
strictObjects: true ->
export const importInput = z.strictObject({
name: z.string(),
seats: z.number().int()
});
Client sends { name, seats, role: "admin" }:
tRPC returns a BAD_REQUEST input error, 'Unrecognized key: role'
role never reaches the resolver.Full Excel-native router in one pass
When you want schema + interface + CRUD from the sheet itself, use the tRPC Router Builder.
/excel-tools/excel-trpc-router on a Members sheet (routerName: memberRouter):
export const MembersSchema = z.object({
name: z.string().optional(),
seats: z.number().optional()
});
export interface Members { name?: string; seats?: number; }
export const memberRouter = router({
list: publicProcedure.input(z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20)
})).query(/* ... */),
// getById, create, update, delete also generated
});Edge cases and what actually happens
Uploading the .xlsx to the JSON to Zod tool
Not acceptedThe redirect target takes JSON only. Convert one row with sheet_to_json first, or use the tRPC Router Builder, which reads .xlsx/.csv directly.
Expecting generated .input() boilerplate
By designThe JSON to Zod generator outputs only the schema and a z.infer<> alias — you compose .input(...) yourself. The tRPC Router Builder is the one that emits full .input()/procedure scaffolding.
Optional fields rejected by the input schema
Add optionalThe JSON to Zod generator never adds .optional(), so a non-required field present in your sample becomes required and rejects payloads that omit it. Add .optional(), or use the tRPC Router Builder, which makes every field optional.
Date column in the input payload
Coerce yourselfDates infer z.string(). tRPC serialises Dates over the wire; for inputs use z.coerce.date() (or z.string().datetime()) so the resolver receives the type it expects.
Batch input with one malformed row
Whole input rejectedz.array(rowSchema) validates the entire array as the procedure input — one bad row fails the whole mutation with a BAD_REQUEST. For partial-success imports, accept z.array(z.unknown()) and safeParse each row inside the resolver instead.
Extra fields in the input silently dropped
Strip by defaultDefault z.object() strips unknown keys, so an unexpected field passes and disappears from input. Use strictObjects: true (-> z.strictObject()) to reject it as a BAD_REQUEST.
tRPC v10 vs v11 compatibility
SupportedThe generated schema is standard Zod, so it works identically as .input() in tRPC v10 and v11. The tRPC Router Builder's emitted procedures use the publicProcedure builder pattern common to both.
Invalid JSON sample
Parse errorThe JSON to Zod generator JSON.parses the input; a malformed paste throws and yields nothing. Use JSON.stringify(rows[0], null, 2) for a clean sample.
Sample over the free limit
413 limitJSON to Zod is Pro with a 2 MB free cap. One row infers the schema — never paste the entire dataset.
Number sent as a string from a form
Coerce or rejectz.number() rejects "5". Use z.coerce.number() in the input schema if your client sends numeric strings, or keep it strict to force the client to send real numbers.
Frequently asked questions
How do I generate a Zod input schema for a tRPC procedure from Excel?
Quick path: convert one sheet row to JSON, paste it at /tool/json-to-zod, and drop the z.object() into .input(...). Full path: use the tRPC Router Builder to emit the schema and procedures from the .xlsx directly.
Can I use the schema for batch imports?
Yes — wrap it: .input(z.array(rowSchema)). The whole uploaded sheet is validated as one mutation input. For partial-success behaviour, validate rows individually inside the resolver instead.
Does it work with tRPC v10 and v11?
Yes. The generated z.object() is standard Zod and works identically as .input() in both. The tRPC Router Builder's procedures use the publicProcedure pattern shared by v10 and v11.
Does the JSON to Zod generator add .optional()?
No. It reads one sample value and never adds .optional(). Add it to non-required fields, or use the tRPC Router Builder, which wraps every field in .optional() after sampling rows.
Will it write the .input() and procedure code for me?
The JSON to Zod generator gives only the schema and a z.infer<> alias — you compose .input(). The tRPC Router Builder generates the full router: schema, interface, and list/getById/create/update/delete procedures.
What does the tRPC Router Builder's only option do?
It exposes a single routerName option (default dataRouter) that names the generated router variable. Type inference samples rows and emits number/string/boolean, with every field .optional().
How do I reject unexpected fields in the input?
Generate with strictObjects: true so the input uses z.strictObject(). Any key not in the schema fails as a BAD_REQUEST. The default z.object() silently strips extras.
How are date inputs handled?
They infer z.string(). Use z.coerce.date() (or z.string().datetime()) in the input schema so the resolver gets the type it expects after deserialization.
Does it read my .xlsx, or do I need JSON?
The JSON to Zod redirect needs a JSON sample (convert with sheet_to_json). The tRPC Router Builder reads .xlsx/.csv headers directly.
Is my spreadsheet uploaded?
No. Both tools run in the browser; your sample never reaches a server. Only an anonymous run counter is logged for dashboard stats.
What's the free size limit?
JSON to Zod is Pro with a 2 MB free file cap; one row is enough to infer the schema. The Excel tools follow the excel family limits (Free 5 MB / 10,000 rows / 1 file).
What if a number arrives as a string from the client?
z.number() rejects "5". Use z.coerce.number() in the input schema to accept numeric strings, or keep it strict to require the client to send real numbers.
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.