| name | type-safe-api-contracts |
| description | Type-safe API contract patterns. tRPC end-to-end typed procedures, ts-rest shared contract definitions, zod-to-openapi spec generation, Zodios Axios client, and contract-first development workflow. Sources: trpc/trpc, ts-rest/ts-rest, asteasolutions/zod-to-openapi, zodios/zodios, ecyrbe/zodios. |
/type-safe-api-contracts
When to Use
- TypeScript monorepo where frontend + backend share the same contract
- "The API changed and the frontend silently broke" — need compile-time safety
- Generating OpenAPI spec from Zod schemas (single source of truth)
- External clients need an OpenAPI doc but you don't want to maintain it manually
Do NOT use for
- Pure REST API consumed by non-TypeScript clients only (use OpenAPI spec directly)
- GraphQL systems (use code-first GraphQL instead)
Decision: tRPC vs ts-rest vs Zodios
Same TypeScript monorepo, full type inference end-to-end?
YES → tRPC (no schema, types inferred from router definition)
NO →
Need REST endpoints compatible with non-TS clients?
YES → ts-rest (typed contract, real HTTP verbs, OpenAPI export)
Need Axios with typed responses from existing OpenAPI?
YES → Zodios (Axios + Zod runtime validation from OpenAPI)
tRPC (end-to-end type inference)
import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.context<{ userId: string }>().create()
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx })
})
export const appRouter = router({
agent: router({
run: protectedProcedure
.input(z.object({ task: z.string().min(1), depth: z.number().int().max(5).default(1) }))
.output(z.object({ result: z.string(), tokensUsed: z.number() }))
.mutation(async ({ input, ctx }) => {
const result = await runAgent(input.task, ctx.userId, input.depth)
return result
}),
history: protectedProcedure
.input(z.object({ limit: z.number().default(20) }))
.query(async ({ input, ctx }) => getHistory(ctx.userId, input.limit)),
}),
})
export type AppRouter = typeof appRouter
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../server/router'
const trpc = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', headers: () => ({ Authorization: `Bearer ${token}` }) })],
})
const { result, tokensUsed } = await trpc.agent.run.mutate({ task: 'summarize repo' })
ts-rest (typed REST contracts)
import { initContract } from '@ts-rest/core'
import { z } from 'zod'
const c = initContract()
export const agentContract = c.router({
run: {
method: 'POST',
path: '/agents/run',
body: z.object({ task: z.string(), sessionId: z.string().uuid() }),
responses: {
200: z.object({ result: z.string(), elapsedMs: z.number() }),
429: z.object({ retryAfter: z.number() }),
},
summary: 'Run an agent task',
},
getResult: {
method: 'GET',
path: '/agents/:id/result',
pathParams: z.object({ id: z.string().uuid() }),
responses: {
200: z.object({ status: z.([, , ]), : z.().() }),
: z.({ : z.() }),
},
},
})
import { createNextRouter } from '@ts-rest/next'
import { agentContract } from '../contracts/agent'
export default createNextRouter(agentContract, {
run: async ({ body }) => {
const result = await executeAgentTask(body.task, body.sessionId)
return { status: 200, body: result }
},
getResult: async ({ params }) => {
const task = await getTask(params.id)
if (!task) return { status: 404, body: { message: 'Not found' } }
return { status: 200, body: task }
},
})
import { initQueryClient } from '@ts-rest/react-query'
import { agentContract } from '../contracts/agent'
const client = initQueryClient(agentContract, { baseUrl: '/api' })
const { data } = client.getResult.useQuery(['agent', id], { params: { id } })
zod-to-openapi (spec generation)
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'
import { z } from 'zod'
const registry = new OpenAPIRegistry()
const AgentRunBody = registry.register('AgentRunBody',
z.object({
task: z.string().min(1).openapi({ example: 'summarize the PR' }),
sessionId: z.string().uuid(),
})
)
const AgentRunResult = registry.register('AgentRunResult',
z.object({
result: z.string(),
elapsedMs: z.number().int(),
})
)
registry.registerPath({
method: 'post',
path: '/agents/run',
summary: 'Execute an agent task',
request: { body: { content: { 'application/json': { schema: } } } },
: {
: { : , : { : { : } } },
: { : },
},
})
generator = (registry.)
spec = generator.({
: ,
: { : , : },
: [{ : }],
})
Zodios (Axios + Zod typed client)
import { makeApi, Zodios } from '@zodios/core'
import { z } from 'zod'
const api = makeApi([
{
method: 'post',
path: '/agents/run',
alias: 'runAgent',
response: z.object({ result: z.string(), elapsedMs: z.number() }),
parameters: [{
name: 'body',
type: 'Body',
schema: z.object({ task: z.string(), sessionId: z.string().uuid() }),
}],
errors: [
{ status: 429, schema: z.object({ retryAfter: z.number() }) },
],
},
])
const client = new Zodios('/api', api)
const { result } = await client.runAgent({ task: 'list PRs', sessionId: crypto.() })
Anti-Fake-Pass Checklist
❌ tRPC .output() missing (server-internal fields can leak to client)
❌ ts-rest path params not in pathParams schema (runtime crash on mismatch)
❌ OpenAPI spec hand-edited instead of generated from Zod (drift guaranteed)
❌ Zodios client used without error schema (429/500 untyped)
❌ tRPC httpBatchLink missing — each call = separate HTTP round-trip
❌ Contract defined in server package, not shared package (client can't import it)
❌ Zod .openapi() metadata omitted — spec examples missing, docs useless