-Principles tRPC (Phantom + Zod) | 99% (end-to-end inference) | 8-12ms (inline parsing) | Instant (single source of truth) | 0.6s | 9.5/10 (zero-delay squiggles) |
Key Findings:
- Phantom generics (
_input, _output) enable zero-cost type inference without runtime overhead.
- Validator schemas (
Zod, Valibot, ArkType) serve dual purposes: runtime payload parsing and compile-time type extraction via z.infer.
- Eliminating codegen reduces build pipeline complexity and removes the primary failure mode in API development workflows.
Core Solution
The architecture relies on two type-system primitives and a transport layer. Once implemented, the router provides end-to-end type safety in ~200 lines of TypeScript.
Trick #1: Path-String Inference at the Type Level
The router returns a value whose type records every procedure name and its input/output shapes as keys in an object. The compiler walks this type tree at check time, enabling precise autocomplete without generated files.
type Procedure<I, O> = {
_input: I;
_output: O;
};
type Router<P extends Record<string, Procedure<any, any>>> = {
_procedures: P;
};
function procedure<I, O>(): Procedure<I, O> {
return {} as Procedure<I, O>;
}
function router<P extends Record<string, Procedure<any, any>>>(
procedures: P,
): Router<P> {
return { _procedures: procedures };
}
const appRouter = router({
greet: procedure<{ name: string }, string>(),
add: procedure<{ a: number; b: number }, number>(),
});
type AppRouter = typeof appRouter;
// AppRouter._procedures.greet._input is { name: string }
// AppRouter._procedures.greet._output is string
Trick #2: Schema-Validator Inference
The input schema is written once as a runtime validator. Its static type (z.infer<typeof schema>) becomes the function signature on the client. One schema, two consumers: runtime parsing and compile-time inference.
import { z } from "zod";
const GreetInput = z.object({ name: z.string().min(1) });
type GreetInput = z.infer<typeof GreetInput>;
// GreetInput is { name: string }
Building it: The Server Side
Three components form the core: a procedure helper that binds schema + handler, a router function that aggregates them into a typed map, and a fetch dispatcher.
// server.ts
import { z, ZodType } from "zod";
type Handler<I, O> = (input: I) => Promise<O> | O;
type Procedure<I, O> = {
_input: I;
_output: O;
schema: ZodType<I>;
handler: Handler<I, O>;
};
export function procedure<S extends ZodType>(schema: S) {
return {
query<O>(handler: Handler<z.infer<S>, O>): Procedure<z.infer<S>, O> {
return {
_input: undefined as unknown as z.infer<S>,
_output: undefined as unknown as O,
schema,
handler,
};
},
mutation<O>(handler: Handler<z.infer<S>, O>): Procedure<z.infer<S>, O> {
return {
_input: undefined as unknown as z.infer<S>,
_output: undefined as unknown as O,
schema,
handler,
};
},
};
}
type AnyProcedure = Procedure<any, any>;
export type Router<P extends Record<string, AnyProcedure>> = {
_procedures: P;
};
export function router<P extends Record<string, AnyProcedure>>(
procedures: P,
): Router<P> {
return { _procedures: procedures };
}
_input and _output are phantom types: they exist only for the compiler. The client reads them to drive parameter checking and return types, while the runtime relies on schema and handler.
// app-router.ts
import { z } from "zod";
import { procedure, router } from "./server";
export const appRouter = router({
greet: procedure(z.object({ name: z.string().min(1) })).query(
({ name }) => `Hello, ${name}`,
),
add: procedure(
z.object({ a: z.number(), b: z.number() }),
).query(({ a, b }) => a + b),
createUser: procedure(
z.object({ email: z.email(), age: z.number().int().min(13) }),
).mutation(async ({ email, age }) => {
return { id: crypto.randomUUID(), email, age };
}),
});
export type AppRouter = typeof appRouter;
There are no path strings, OpenAPI tags, or schema exports. The router itself is the single source of truth. (If you are still on Zod 3, swap z.email() for z.string().email(). The top-level format helpers landed in Zod 4.)
Pitfall Guide
- Skipping Runtime Validation: Relying solely on
Procedure<I, O> without a validator like Zod removes runtime payload parsing. Malformed JSON will bypass TypeScript checks and crash handlers. Always bind a schema to the procedure.
- Treating Phantom Types as Runtime Values:
_input and _output are compile-time only. Attempting to read proc._input at runtime returns undefined. Use proc.schema for validation and proc.handler for execution.
- Zod Version Mismatch: Zod 3 and Zod 4 differ in API surface (e.g.,
z.email() vs z.string().email()). Mixing versions across client/server monorepos breaks z.infer resolution. Pin versions and align schema definitions.
- Unconstrained Router Nesting: Deeply nested routers without
Record<string, AnyProcedure> constraints cause TypeScript's recursive type inference to hit depth limits, resulting in Type instantiation is excessively deep and possibly infinite errors. Flatten or explicitly type intermediate routers.
- Ignoring Context Propagation: Procedures that require auth, DB sessions, or request metadata must thread context through the handler signature. Failing to design a context type early forces breaking changes across all procedures later.
- Missing Error Boundary Mapping: tRPC standardizes error shapes (code, message, data). Custom routers must explicitly catch Zod validation errors and map them to HTTP 400/422 responses with consistent JSON payloads, or clients will fail to parse failures.
- Over-Engineering Middleware Chains: Adding middleware before the core router loop works, but excessive chaining breaks type inference for input/output shapes. Keep middleware focused on cross-cutting concerns (logging, auth) and avoid mutating the
I/O types mid-chain.
Deliverables
- Blueprint: First-Principles tRPC Router Architecture β A system diagram mapping phantom type flow, schema-validator dual-use, and HTTP transport dispatch. Includes type relationship maps for
Procedure, Router, and AppRouter.
- Checklist: Type-Safe API Implementation Checklist β 12-step verification covering schema binding, phantom type validation, error boundary mapping, Zod version alignment, and client-side type import strategy.
- Configuration Templates: Zod 4 + TypeScript 5.6+ Router Config β Pre-configured
tsconfig.json (strict mode, exactOptionalPropertyTypes), package.json scripts for zero-codegen builds, and a starter server.ts/app-router.ts scaffold ready for Node/Bun/Deno deployment.