
Can AI Replace Manual Testers?
20th May 2026Why thousands of teams are ditching hand-written API contracts for end-to-end type safety — and what it means for the way we build full-stack apps forever.
For a decade, we debated REST versus GraphQL like it was theology. One camp fetched over HTTP verbs; the other wrote resolvers and schemas until their fingers bled. Both camps shipped bugs. Both lost weekends to “the API says it returns a string but the frontend got undefined”. Both paid the tax of maintaining two worlds — the server’s truth and the client’s guess.
In 2026, a growing majority of TypeScript teams have moved on. tRPC and Zod, used together, make the contract implicit, inferred, and compile-time enforced. There’s nothing to document. There’s nothing to generate. There’s nothing to drift.
“If your client and server are both TypeScript, writing a REST or GraphQL layer between them is like translating English to French and then back to English — just to send a message to yourself.”
The Problem With “Contracts You Write By Hand”
REST APIs have an elegant philosophy but a painful practice. You design a route, write a controller, optionally slap on an OpenAPI schema, and then — if you’re disciplined — run a codegen tool on the frontend so you get types back. Every one of those steps is a place to forget, to drift, to lie.
GraphQL improved the schema story considerably. At least now there’s a single SDL file that both sides agree on. But you still maintain that SDL. You still run codegen. You still have a build step that turns your schema into TypeScript types, and when a developer changes a resolver and forgets to regenerate — which happens every sprint — you’re back to runtime surprises.
| OLD WAY — REST + OpenAPI | NEW WAY — tRPC + Zod |
| ✗ Manual schema maintenance | ✓ Types inferred automatically |
| ✗ Codegen that gets skipped | ✓ Zero codegen steps needed |
| ✗ Runtime type mismatches | ✓ Compile-time API errors |
| ✗ No shared validation logic | ✓ Zod validates both sides |
| ✗ Client & server out of sync | ✓ Single source of truth |
| ✗ Swagger docs that lie | ✓ Docs are just the code |
What tRPC Actually Is
tRPC is not a framework in the traditional sense. It doesn’t replace HTTP — your procedures still run over HTTP. What it replaces is the representation layer: instead of JSON endpoints with vague shapes, you export TypeScript router objects, and the client imports the type of that router directly. No generation, no schema files — just TypeScript’s structural type system doing what it was born to do.
Server-side: Define your router with Zod input validation
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']),
age: z.number().min(13).max(120),
});
export const appRouter = t.router({
user: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
return db.users.findUnique({ where: { id: input.id } });
}),
create: t.procedure
.input(UserSchema.omit({ id: true }))
.mutation(async ({ input }) => {
return db.users.create({ data: input });
}),
}),
});
export type AppRouter = typeof appRouter;
Client-side: The type is just an import — no codegen
// client/api.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCReact<AppRouter>();
// In a component — fully typed, autocompleted, compile-safe
const { data: user } = trpc.user.getById.useQuery({
id: '550e8400-e29b-41d4-a716-446655440000'
});
// user is: { id: string; email: string; role: 'admin'|'member'|'viewer'; age: number }
// Your IDE knows. The compiler knows. No codegen ran.
When you change a procedure’s input shape on the server, every call site on the client turns red in your editor — instantly, before you touch a browser.
Where Zod Becomes the Hero
tRPC handles the transport and type-sharing layer. Zod handles the validation — and the beautiful thing is that Zod schemas are TypeScript-native objects, so the type you declare for validation is the type you use everywhere else. There’s no duplication between “the interface” and “the validator.”
One schema, used in API, forms, DB, and tests
// shared/schemas.ts — one file, used everywhere
import { z } from 'zod';
export const CreatePostSchema = z.object({
title: z.string().min(3).max(120),
slug: z.string().regex(/^[a-z0-9-]+$/),
body: z.string().min(10),
tags: z.array(z.string()).max(5),
publish: z.boolean().default(false),
});
// Infer the TypeScript type — no separate interface needed
export type CreatePost = z.infer<typeof CreatePostSchema>;
// React Hook Form uses the same schema — same rules, same errors
const form = useForm<CreatePost>({
resolver: zodResolver(CreatePostSchema),
});
procedure input, on the React Hook Form, on the database insert, and in your unit tests. When business rules change — say, a title can now be 200 characters — you change one number in one file, and every surface that enforces that rule updates automatically.
| 0 Codegen steps in your CI pipeline | 1 Schema per domain, shared everywhere | ~40% Less boilerplate vs REST+OpenAPI |
Migration in Practice: A Step-by-Step Path
You don’t have to rewrite everything. tRPC coexists with existing REST or GraphQL APIs — you adopt it incrementally, one domain at a time.
Step 1 — Install and wire up the server adapter
tRPC has adapters for Next.js App Router, Express, Fastify, Hono, and more. Drop in the adapter alongside your existing routes — no big-bang rewrite required.
Step 2 — Define Zod schemas for one domain
Pick your most painful endpoint — the one where type drift causes bugs the most. Wrap its inputs and outputs in Zod schemas and move them to a shared module.
Step 3 — Replace fetch calls with trpc.useQuery / useMutation
React Query is built in. You get caching, optimistic updates, loading states, and full type safety — replacing the hand-rolled useEffect + fetch patterns that live in every codebase.
Step 4 — Add middleware for auth, logging, rate-limits
tRPC’s middleware system is composable and typed. Attach a session to your context once; every procedure that needs it gets a typed ctx.user automatically.
Step 5 — Retire old endpoints as you cover them
Track coverage by domain. Run both systems in parallel during the transition. Once a tRPC procedure has feature-parity, delete the REST route. One domain at a time, your API becomes the type system.
When to Still Use REST or GraphQL
tRPC is not a universal hammer. It requires that both your client and server are TypeScript — or at least that you control both ends. If you’re building a public API consumed by third parties, REST with OpenAPI is still the right choice: it gives consumers a language-agnostic contract.
If your team is polyglot, or you have mobile apps in Swift and Kotlin talking to the same backend, GraphQL’s SDL is a better shared contract. The magic of tRPC is specifically for the monorepo, full-stack TypeScript world — which in 2026 describes a majority of new product teams. If that’s you, the cost-benefit analysis is overwhelming.
The contract was always there — now it’s enforced
REST and GraphQL asked you to maintain the contract as a separate artifact. tRPC + Zod make the contract identical to the code itself. When your editor knows the shape of every API call at every call site — without a build step, without a schema file, without a separate type package — you stop writing defensive code and start shipping features. That’s not a small improvement. It’s a different way of thinking about what a ‘backend’ even is.





