How to from excel data to end-to-end type-safe trpc client and server
- Step 1Treat the sheet as the canonical field list — Use the spreadsheet that authoritatively defines the entity's fields. The first row becomes the typed fields; the inferred types propagate to the client, so this sheet is your source of truth for the whole type chain.
- Step 2Give type inference clean samples — Inference reads the first 50 non-empty values per column. Numeric ids/amounts as numbers, flags as
true/false, dates asYYYY-MM-DD— so the inferred Zod (and therefore client) types are precise rather than falling back toz.string(). - Step 3Generate on a Developer account — The tool is Developer-tier; otherwise you get an upgrade overlay and the error
tRPC Router Builder requires Developer tier.Drop the.xlsx/.csv; SheetJS parses it locally. - Step 4Name the router — Pick a router name (e.g.
customersRouter). The exportedexport type CustomerRouter = typeof customersRouteris what you hand to the client; the entity name (Customer) is derived from the router name. - Step 5Type your client from the exported router type — On the server, the router type flows automatically through your
appRouter. For a standalone or external client, use the exported${Entity}Routertype to parameterizecreateTRPCClient/createCallerso calls are fully inferred. - Step 6Reuse the exported interface where you need a plain type — Import
interface ${Entity}directly in components or shared utilities when you want the shape without the tRPC machinery (e.g. typing a prop or a non-tRPC fetch). It mirrors the Zod fields.
The end-to-end type chain
How a spreadsheet column propagates all the way to an inferred client type. No link in this chain is hand-duplicated.
| Stage | Generated artifact | Type carried |
|---|---|---|
| Excel column | header → field name | name + sampled values |
| Zod schema | ${Entity}Schema field | z.number() / z.string() / z.boolean(), .optional() |
| tRPC input | procedure .input(...) | inferred input type from the Zod schema |
| Router type | export type ${Entity}Router = typeof <routerName> | the full router shape |
| Client | createTRPCClient<${Entity}Router>() / hooks | inferred query/mutation types — no manual decl |
Read vs write inferred types
The generator deliberately produces different schemas for read and write, so client input types differ by operation.
| Schema | Definition | Effect on inferred input |
|---|---|---|
${Entity}Schema | all fields .optional() | Loose read shape; every field may be undefined |
${entity}CreateSchema | ${Entity}Schema.required() | Create input requires every field |
${entity}UpdateSchema | .partial() + id: z.string().uuid() | Update input: all fields optional except a required uuid id |
getById / delete input | { id: z.string().uuid() } | Client must pass a uuid string id |
Inputs, output, and tier limits
Developer-only; one workbook in, one TypeScript file (router + types) out.
| Property | Value |
|---|---|
| Input formats | .xlsx, .xls, .ods, .csv |
| Output | One .ts file: schema, interface ${Entity}, router, export type ${Entity}Router |
| Date typing | ISO YYYY-MM-DD → z.string() (not z.date()) |
| Options | routerName only (default dataRouter) |
| Required tier | Developer |
| Developer file / rows | 500 MB / unlimited |
| Multi-entity | Multi-tab workbook → root router; root type is export type ${PascalRouterName} |
Cookbook
Using the generated exports to get true end-to-end type safety, plus the inference subtleties that shape client types.
Server router type flows to the client
Generate customersRouter; mount it on appRouter; the client infers everything. No duplicated Customer type on the front end.
Input (sheet: Customers): id,name,email,active
Router name: customersRouter
Generated exports:
export const customersRouter = router({ list, getById, create, update, delete });
export interface Customer { id?: number; name?: string; email?: string; active?: boolean; }
export type CustomerRouter = typeof customersRouter;
Client (types inferred, zero manual decls):
const { data } = api.customers.list.useQuery({ page: 1, pageSize: 20 });
// data: { data: Customer[]; page: number; pageSize: number } (inferred)Type a standalone client from the exported router type
For an external/standalone client you parameterize createTRPCClient with the exported router type.
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { CustomerRouter } from "./customersRouter";
const client = createTRPCClient<CustomerRouter>({
links: [httpBatchLink({ url: "/api/trpc" })],
});
await client.getById.query({ id: "..." }); // fully typed from the exportRead vs write input types differ
Because the base schema is optional but createSchema is required, the inferred create input demands every field while read shapes are loose.
// Inferred from generated schemas: type CustomerRead = z.infer<typeof CustomerSchema>; // all fields optional type CustomerCreate = z.infer<typeof customerCreateSchema>; // all fields required type CustomerUpdate = z.infer<typeof customerUpdateSchema>; // partial + id: string(uuid) api.customers.create.useMutation(); // input requires every field api.customers.update.useMutation(); // input: partial + required id
Reuse the interface without tRPC
The exported interface is a plain TS type you can import anywhere, including non-tRPC code paths.
import type { Customer } from "./customersRouter";
function CustomerBadge({ customer }: { customer: Customer }) {
return <span>{customer.name}</span>;
}
// Same shape the server validates -> no drift between prop type and API.Rename a field and let the compiler find usages
Change a column header, regenerate, and TypeScript flags every stale reference on the client — the payoff of a single source of truth.
Spreadsheet: rename 'email' -> 'emailAddress', regenerate. Generated interface now: emailAddress?: string (email removed) Client code still using customer.email: Property 'email' does not exist on type 'Customer'. Did you mean 'emailAddress'? // One regenerate surfaces every place that must change.
Edge cases and what actually happens
Date columns typed z.string(), not z.date()
By designISO YYYY-MM-DD columns infer to z.string(), so the client type for that field is string, not Date. If your client expects a Date, tighten the field to z.coerce.date() after generating — the inferred client type updates accordingly.
All-optional base schema loosens read types
ExpectedEvery field in ${Entity}Schema is .optional(), so the inferred read type marks each field as possibly undefined. That's intentional (partial records are valid on read); create re-requires all fields via .required(). If a field is always present on read, mark it required in the base schema by hand.
id inferred as number but uuid in inputs
Type mismatch to reconcileThe schema/interface types id from your data (often number), but getById/delete/update require z.string().uuid(). The client will need a string id for those calls while the interface says number. Reconcile by editing the id validator to match your real key type so client types are consistent.
Developer tier required
Developer requiredOn Free / Pro / Pro+Media the tool throws tRPC Router Builder requires Developer tier. and shows an upgrade overlay — no router, no types. Generate on a Developer account.
Mixed-type column collapses to string
z.string() fallbackIf a column mixes numbers with any non-numeric value, it can't be z.number() and falls back to z.string(), so the client type becomes string. Clean the source column if you need a numeric client type.
Multi-sheet root type name
Multi-sheet behaviourFor a multi-tab workbook the exported root type is export type ${PascalCase(routerName)} (e.g. AppRouter for appRouter), and sub-router entity names come from sheet names. Type your client from this root type and name tabs deliberately.
Empty column yields string type
z.string() fallbackA column with no non-empty samples infers z.string(), giving a string client type even if the field is meant to be numeric or boolean. Include sample data for every column you want strongly typed.
Headers must be in row 1
Headers from row 1SheetJS reads row 1 as field names, which become your typed fields. A stray preamble row would produce nonsense field names (and types) on the client. Keep only the field names in row 1.
Duplicate headers get suffixed
Auto-suffixedTwo columns with the same header are disambiguated by SheetJS (e.g. email, email_1), so the client interface gains a suffixed field. Rename duplicates in the source to keep clean client types.
Output preview truncated
Preview onlyThe on-page preview shows the first 4,000 characters. Copy (full string) or Download to get the complete file, including all exported types, before importing on the client.
Frequently asked questions
How does Excel give me end-to-end type safety?
The spreadsheet headers define the Zod schema, the Zod schema defines the tRPC input types, and tRPC flows the router type to the client. So one source — your sheet — drives server validation and inferred client types, with no duplicated declarations. Regenerate when the sheet changes and the types propagate.
What does the tool export for client typing?
Three things: the Zod schemas, a plain export interface ${Entity}, and export type ${Entity}Router = typeof ${routerName}. Use the router type to parameterize createTRPCClient or a server caller; use the interface for plain TS typing anywhere.
Can I use this with React Query directly without tRPC?
Yes — the exported interface ${Entity} and the Zod schemas are usable independently of tRPC, with any data-fetching library. You lose the automatic router-type inference, but you keep a single typed shape for your fetchers and forms.
Why are read and write input types different?
By design. The base ${Entity}Schema is all-.optional() (read shape), ${entity}CreateSchema is .required() (every field on create), and ${entity}UpdateSchema is .partial() plus a required uuid id. So create demands all fields while update accepts a partial — exactly the input types you want per operation.
Why is my date field a string on the client?
ISO YYYY-MM-DD columns are typed z.string(), so the inferred client type is string. To get a Date, change those fields to z.coerce.date() after generating — the client type updates to Date automatically.
How do I handle the id-type mismatch?
The interface types id from your data (usually number), but getById/delete/update require z.string().uuid(). Pick the real key type and make both consistent — e.g. change the id validator to z.number().int() for a numeric PK so the client passes a number throughout.
Does it generate OpenAPI/Swagger docs?
Not directly. The tool produces a tRPC router; if you need an OpenAPI spec, add a tRPC-OpenAPI package to your project and run it against the generated router. The router's typed inputs/outputs are what those tools introspect.
What's the only option?
Router name (default dataRouter). It names the output file, the exported router type, and (for a single sheet) the entity name. There's no separate type-export or naming option — the exports are emitted automatically.
What tier and limits apply?
Developer tier only; lower tiers see an upgrade overlay. Developer raises the per-file limit to 500 MB with unlimited rows. It's a single-workbook tool, with multi-sheet workbooks producing a root router whose type you can use for the client.
Does my authoritative schema leave my machine?
No. SheetJS parses the file in your browser; headers and sample data stay local. Only an anonymous processed-file counter is recorded for dashboard stats.
Which tRPC version do the types target?
tRPC v11. The router imports from ../trpc and @trpc/server, and the exported typeof router type works with the v11 client helpers (createTRPCClient, React Query hooks, server callers).
If I rename a column, will the client break loudly?
Yes — that's the benefit. Rename a header, regenerate, and the exported interface/router type changes. TypeScript then flags every client usage of the old field name at compile time, so nothing silently uses a stale field. If you want just the type without the router, json-to-typescript emits a standalone interface, and json-to-zod emits a standalone Zod schema.
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.