How to generate zod input schemas for trpc router procedures
- Step 1Capture a sample procedure input — Take a representative argument object for the query or mutation, e.g.
{ "postId": "abc", "comment": { "body": "hi", "pinned": false } }. Include every argument; inference only sees keys present in the sample and types them by value. - Step 2Drop the JSON onto the tool — Drag a
.jsonfile onto the dropzone above. Spreadsheet fixtures (.xlsx,.xls,.ods) are read as a JSON array of row objects if that is where your test inputs live. - Step 3Name the schema after the procedure — Set Root schema name to
createPostInputor similar. You getexport const createPostInput = z.object({...})and the typeCreatePostInput, which is handy for sharing the input type explicitly. - Step 4Choose Strict objects for a tight contract — Tick Strict objects so
z.strictObjectrejects any argument not in the procedure's declared input. Leave it off if you intentionally accept extra fields. - Step 5Generate and copy the schema — Click Generate Schema, then Copy or Download .ts. The module begins with
import { z } from "zod";. - Step 6Wire it into the procedure and refine — Use it as the input:
publicProcedure.input(createPostInput).mutation(({ input }) => { ... }). Then add the constraints the generator can't infer:postId: z.string().cuid(),title: z.string().min(1).max(120).
Procedure input value to Zod type
What the generator emits per argument, and the constraint you typically add for a tRPC input contract.
| Sample argument | Generated | Add for the contract |
|---|---|---|
"clx123" (cuid/id) | z.string() | .cuid() or .uuid() |
"a@b.com" | z.string() | .email() |
10 (limit/cursor) | z.number().int() | .int().min(1).max(100) |
3.5 (rating) | z.number() | .min(0).max(5) |
false | z.boolean() | leave as-is or .default(false) |
{ "filter": {...} } | nested z.object({...}) | constrain inner fields |
["a","b"] (ids) | z.array(z.string()) | .min(1) |
null | z.null() | rewrite to z.string().nullable() |
Structural-only behaviour vs what tRPC needs
The generator handles structure; these tRPC-specific concerns are manual follow-ups.
| Need | Generator does it? | How you handle it |
|---|---|---|
| Object shape + nesting | Yes | Inferred automatically |
| Reject unknown keys | Yes (Strict on) | Tick Strict objects → z.strictObject |
.uuid() / .cuid() on ids | No | Add by hand |
| Optional / default args | No | Add .optional() / .default() |
| Discriminated union input | No | Build z.discriminatedUnion manually |
| Output (return) schema | No | Generate separately, or rely on inference |
Cookbook
Sample inputs, the schema produced, and the procedure definition you wrap around it.
A query input with pagination
ExampleGenerate the structural skeleton, then add the range constraints tRPC list procedures usually want.
Input sample:
{ "cursor": "abc", "limit": 20 }
Generated:
import { z } from "zod";
export const listPostsInput = z.object({
cursor: z.string(),
limit: z.number().int()
});
export type ListPostsInput = z.infer<typeof listPostsInput>;
Used (you add constraints):
listPosts: publicProcedure
.input(listPostsInput.extend({ limit: z.number().int().max(100) }))
.query(({ input }) => /* ... */);A mutation with a nested object
ExampleNested input objects become nested z.object blocks, matching the argument structure exactly.
Input sample:
{ "postId": "abc", "comment": { "body": "hi", "pinned": false } }
Generated:
export const addCommentInput = z.object({
postId: z.string(),
comment: z.object({
body: z.string(),
pinned: z.boolean()
})
});
Used:
addComment: protectedProcedure
.input(addCommentInput)
.mutation(({ input }) => /* input.comment.body is typed */);Array-of-ids batch mutation
ExampleA homogeneous array collapses to a single element type. Add a non-empty constraint for batch operations.
Input sample:
{ "ids": ["a", "b", "c"], "published": true }
Generated:
export const bulkUpdateInput = z.object({
ids: z.array(z.string()),
published: z.boolean()
});
Refined:
.input(bulkUpdateInput.extend({
ids: z.array(z.string().cuid()).min(1)
}))Strict input to lock the contract
ExampleWith Strict objects on, a client sending an undeclared argument is rejected at the procedure boundary — a strong guarantee for end-to-end safety.
Strict objects: ON
export const getPostInput = z.strictObject({
id: z.string()
});
getPost: publicProcedure.input(getPostInput).query(...)
// A call with { id, debug: true } throws Unrecognized key(s).Optional argument the generator can't see
ExampleIf an optional arg wasn't in the sample, add it after generating. tRPC respects .optional() and .default() on the input.
Generated (only required args seen):
export const searchInput = z.object({ q: z.string() });
You add the optional filter:
export const searchInput = z.object({
q: z.string(),
category: z.string().optional(),
sort: z.enum(['new','top']).default('new')
});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.
id field comes out as z.string(), not .cuid()/.uuid()
By designThe generator infers structure from value types, so any id string is z.string(). Add .cuid(), .uuid(), or .regex(...) to match your id format. This keeps inference deterministic — it never guesses an id convention.
Optional argument missing from the sample
Missing keyOnly keys present in the sample input are inferred. An optional argument you left out will not be in the schema. Add it as .optional() (or .default(...)) by hand, or include it in the sample with a representative value.
Argument was null in the sample
Narrowed typeA null argument produces z.null(), accepting only null. For a nullable argument, rewrite to z.string().nullable() (or the appropriate base). The generator can't infer the non-null type from one null.
Polymorphic input across procedures
Object unionIf you paste an array whose objects differ in shape, the generator emits z.array(z.union([...])) rather than a discriminated union. tRPC inputs that vary by a type field need a hand-built z.discriminatedUnion('type', [...]) — the generator can't infer the discriminator.
Mixed-type array argument
Union elementAn array like [1, "x"] yields z.array(z.union([z.number().int(), z.string()])). Faithful to the sample, but for a clean contract you usually want a single element type — normalise the sample or tighten the schema by hand.
Empty array argument
Unknown elementAn empty array ([]) becomes z.array(z.unknown()). Provide at least one element in the sample so the element type can be inferred, or replace z.unknown() with the intended element schema.
Return/output schema not generated
Input onlyThis tool generates one schema from one sample, which you use as the procedure input. tRPC usually infers the output type from your resolver, so you rarely need an explicit .output(). If you do, generate a separate schema from a sample response.
Free tier daily run cap
Quota reachedFree tier permits 1 generation per day (UTC reset) with array inputs capped at 100 rows. Each procedure schema is one run. Generate your most-complex inputs first, or upgrade — Pro is 5/day and Developer is unlimited.
Frequently asked questions
How do I use the output as a tRPC input validator?
Pass the generated schema directly to .input(...): publicProcedure.input(createPostInput).mutation(({ input }) => { ... }). tRPC runtime-validates the args against the schema and infers the input type for both server and client.
Does it add .uuid() or .cuid() to my id fields?
No. Any string value becomes z.string(). Add .uuid(), .cuid(), or a .regex(...) matching your id format after generating. The generator never guesses an id convention from the value.
Will this give me end-to-end type safety?
Yes, indirectly. tRPC derives the procedure's input type from the Zod schema you pass to .input(). Because this tool produces that schema (with an exported z.infer type to match), the client call site is typed and a wrong argument shape fails to compile.
How do I make an argument optional or give it a default?
Add .optional() or .default(value) after generating. The generator marks every key it sees as required because absence isn't observable from a single sample. tRPC honours both modifiers on the input schema.
Can it produce a discriminated union for polymorphic inputs?
No. If your input varies by a type field, the generator unions the observed shapes but can't know the discriminator. Convert to z.discriminatedUnion('type', [...]) by hand once you know the variants.
Should I enable Strict objects?
For a tight end-to-end contract, yes — z.strictObject rejects undeclared arguments at the boundary, catching stale clients. Leave it off if you intentionally accept extra fields (rare for tRPC inputs).
Does it generate the output (return) schema too?
No. It produces one input schema per run. tRPC typically infers outputs from your resolver, so an explicit .output() is optional. If you want one, generate a separate schema from a sample response payload.
Are my sample inputs uploaded anywhere?
No. Generation runs entirely in your browser; sample inputs are never transmitted to JAD Apps. Strip any real tokens from fixtures before pasting — only the shape matters.
How do I combine several procedure schemas?
Run the tool once per procedure, naming each root (e.g. createPostInput, listPostsInput), and paste the exports into one inputs.ts module. Each generation yields a single root schema.
What if I want to extend a generated input later?
Use Zod's .extend({...}) or .merge(otherSchema) on the generated const. This is the cleanest way to add constraints (limit: z.number().int().max(100)) or extra optional fields without editing the generated block.
Can I feed it a fixture spreadsheet of inputs?
Yes — an .xlsx, .xls, or .ods file is read as its first sheet and converted to a JSON array of row objects. You'll get z.array(z.object({...})); lift the inner object as your single-input schema.
I'd rather have a TypeScript interface for the input — is that possible?
Use json-to-typescript for a plain interface. For tRPC you generally want Zod (it gives runtime validation plus the inferred type), but if you only need the static type, the TypeScript generator is the right sibling tool.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.