Skip to content
rogeriolaaPublic

About

End-to-end type-safe RPC in ~300 lines of TypeScript — no codegen, no framework. Plain node:http + fetch, inspired by tRPC.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mini-trpc

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 call

Quickstart

Requires 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.

How it works

Two halves, one shared contract:

  1. Compile-time mirror — InferClient<T> walks the server router type with keyof + infer and turns every Procedure<I, O> into (input: I) => Promise<O>. Nested routers recurse. The client imports only import type { AppRouter }, so no server code ships to the client.
  2. Runtime path call — the client is a Proxy that accumulates property accesses (user → getById) and fires POST /trpc/user.getById on 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.
  3. 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.

Protocol

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", ... } }

Project structure

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

Limitations (deliberate)

  • POST for queries too — real tRPC uses GET for queries (caching, batching). One code path keeps v1 learnable.
  • JSON-only — Date/Map/BigInt don'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.

Companion article

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.

License

MIT — see LICENSE.

About

End-to-end type-safe RPC in ~300 lines of TypeScript — no codegen, no framework. Plain node:http + fetch, inspired by tRPC.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages