End-to-end type-safe RPC in ~300 lines of TypeScript — no codegen, no framework, no React.
Plain node:http on the server. fetch on the client. strict: true everywhere. Inspired by tRPC, rebuilt from scratch to understand how the type magic actually works.
// server — define procedures
export const appRouter = t.router({
user: t.router({
getById: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => users.find((u) => u.id === input.id) ?? null),
}),
});
export type AppRouter = typeof appRouter;
// client — import the TYPE only (zero runtime bytes), get full autocomplete
import type { AppRouter } from "./router";
const client = createClient<AppRouter>({ url: "http://localhost:3000/trpc" });
await client.user.getById.query({ id: "1" }); // ^? { id: string; name: string } | null
await client.user.getById.query({ id: 123 }); // ❌ compile error — before any network callRequires Node 20+.
npm install
npm test # 5 tests: roundtrip, mutation, Zod rejection, 404, type inference
npm run start:server # demo UI + API on :3000 (plain node:http) — open http://localhost:3000
npm run demo:client # run the typed client demo (separate terminal)Try breaking it on purpose: change { id: "1" } to { id: 123 } in src/example/client.demo.ts and run npx tsc --noEmit. A network bug, caught without a network.
Two halves, one shared contract:
- Compile-time mirror —
InferClient<T>walks the server router type withkeyof+inferand turns everyProcedure<I, O>into(input: I) => Promise<O>. Nested routers recurse. The client imports onlyimport type { AppRouter }, so no server code ships to the client. - Runtime path call — the client is a
Proxythat accumulates property accesses (user→getById) and firesPOST /trpc/user.getByIdon the final.query/.mutation. The server resolves the same dotted path against the same router object. If type and runtime disagree, you get a red squiggle or a 404 — never silent drift. - Zod as the bridge — one schema gives a static input type (
z.infer) for the client and runtime validation for the server. Types check the caller you control; validation checks the caller you don't.
POST /trpc/<dotted.path> Content-Type: application/json
Body: { "input": <unknown> }
| Case | Response |
|---|---|
| Success | 200 { "result": <data> } |
| Zod validation failure | 400 { "error": { "code": "BAD_REQUEST", "message", "issues" } } |
| Unknown path | 404 { "error": { "code": "NOT_FOUND", ... } } |
| Non-POST method | 405 { "error": { "code": "METHOD_NOT_ALLOWED", ... } } |
| Handler throw | 500 { "error": { "code": "INTERNAL_ERROR", ... } } |
src/
trpc/
types.ts # Procedure, RouterRecord, isProcedure guard
server.ts # initTRPC(), router(), createHTTPHandler() (node:http)
client.ts # InferClient<T>, createClient() (fetch + Proxy)
example/
router.ts # demo user.getById / list / create + AppRouter
server.ts # node:http wiring
client.demo.ts # typed usage demo
tests/
roundtrip.test.ts # real HTTP roundtrip on an ephemeral port
types.test.ts # expectTypeOf inference check
MEDIUM-ARTICLE.md # companion article draft
- POST for queries too — real tRPC uses GET for queries (caching, batching). One code path keeps v1 learnable.
- JSON-only —
Date/Map/BigIntdon't survive the wire. A real fix is superjson-style serialization. - No batching, auth, subscriptions, or streaming — each is a follow-up project on its own.
- 1 MB body guard — proves the thought, not production-hardened.
MEDIUM-ARTICLE.md is the full write-up: "REST Is a Lie We All Agreed to Believe. I Proved It in 300 Lines." — the procedure builder, the 10-line type mirror, and the 20-line Proxy, piece by piece.
MIT — see LICENSE.