Type-Safe Full-Stack Development with tRPC, Drizzle, and Zod
If you’ve ever shipped a bug because your frontend expected user.firstName but your API was returning user.first_name, you know the pain. That silent mismatch — no compiler warning, no red squiggles, just a undefined rendering in production — is the kind of thing that makes you question your career choices at 2 AM.
The good news? That entire category of bugs can be eliminated. Not reduced. Eliminated.
The modern TypeScript full-stack ecosystem has converged on a powerful trio: tRPC for end-to-end type-safe APIs, Drizzle ORM for type-safe database access, and Zod for runtime schema validation. Together, they create an unbroken chain of type safety from your database columns all the way to your React components — and the developer experience is genuinely transformative.
Let’s break down how these tools work together and build something real.
The Problem: Type Safety Has Gaps
Most full-stack TypeScript projects feel type-safe but aren’t. Here’s where the illusions typically break down:
- Database → Server: Your ORM returns
anyor loosely typed objects. You write manual interfaces to describe what you think the shape is. - Server → Client: You define REST endpoints, then write
fetchcalls on the frontend and manually type the response. If the backend changes, the frontend has no idea. - User Input → Server: You trust
req.bodyand cast it to a TypeScript type, but there’s no runtime guarantee that the data matches.
Each of these gaps is a place where bugs hide. The tRPC + Drizzle + Zod stack closes all three.
Layer 1: Drizzle ORM — Type Safety Starts at the Database
Drizzle ORM has surged in popularity as a lightweight alternative to Prisma, and for good reason. It’s SQL-like, has zero generation steps, and produces TypeScript types directly from your schema definition.
Here’s a basic schema:
// src/db/schema.ts
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Automatically generate Zod schemas from your Drizzle table
export const insertUserSchema = createInsertSchema(users, {
email: z.string().email('Invalid email address'),
name: z.string().min(2, 'Name must be at least 2 characters'),
age: z.number().min(13, 'Must be at least 13').optional(),
});
export const selectUserSchema = createSelectSchema(users);
// TypeScript types derived from the schemas
export type InsertUser = z.infer<typeof insertUserSchema>;
export type User = z.infer<typeof selectUserSchema>;
Notice what just happened. We defined our database table once, and from that single source of truth we got:
- A fully typed database table for queries
- A Zod validation schema for inserts (with custom refinements)
- A Zod schema for selects
- TypeScript types for both
No duplication. No drift. The drizzle-zod package is the glue here, and it’s what makes this stack sing.
Layer 2: tRPC + Zod — The API Layer That Doesn’t Exist
This is where tRPC changes everything. Instead of defining REST routes, writing fetch calls, and manually typing responses, tRPC lets your frontend directly call server functions with full type inference. There’s no API contract to maintain because there’s no API layer in the traditional sense.
Here’s how we wire up a tRPC router using our Drizzle schema and Zod validation:
// src/server/routers/user.ts
import { router, publicProcedure } from '../trpc';
import { db } from '../db';
import { users, insertUserSchema } from '../db/schema';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
export const userRouter = router({
// Get all users
list: publicProcedure.query(async () => {
return await db.select().from(users);
// Return type is automatically inferred as User[]
}),
// Get a single user by ID
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const result = await db
.select()
.from(users)
.where(eq(users.id, input.id));
return result[0] ?? null;
// Return type: User | null — fully inferred
}),
// Create a new user with full Zod validation
create: publicProcedure
.input(insertUserSchema) // Reusing our Drizzle-generated Zod schema!
.mutation(async ({ input }) => {
const result = await db.insert(users).values(input).returning();
return result[0];
}),
// Update a user
update: publicProcedure
.input(
z.object({
id: z.number(),
data: insertUserSchema.partial(), // Partial updates for free
})
)
.mutation(async ({ input }) => {
const result = await db
.update(users)
.set(input.data)
.where(eq(users.id, input.id))
.returning();
return result[0] ?? null;
}),
});
A few things to appreciate here:
- The
insertUserSchemawe generated from Drizzle is reused as tRPC input validation. One schema, three purposes (DB types, runtime validation, API contract). - tRPC uses Zod for runtime schema validation automatically. If a client sends
{ age: "not a number" }, the request is rejected before your handler code runs, with a structured error message. - Every return type is inferred. We never wrote a single
interfacefor our API responses.
Layer 3: The Frontend — Where the Magic Is Visible
Now here’s the payoff. On the frontend, your tRPC client knows everything about your API:
// src/components/UserProfile.tsx
import { trpc } from '../utils/trpc';
export function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading } = trpc.user.byId.useQuery({ id: userId });
const updateUser = trpc.user.update.useMutation();
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
const handleUpdateName = async () => {
await updateUser.mutateAsync({
id: userId,
data: { name: 'New Name' },
});
};
return (
<div>
{/* ✅ TypeScript knows `user.name` is a string */}
<h1>{user.name}</h1>
{/* ✅ TypeScript knows `user.email` is a string */}
<p>{user.email}</p>
{/* ✅ TypeScript knows `user.age` is number | null */}
{user.age && <p>Age: {user.age}</p>}
{/* ❌ TypeScript ERROR: Property 'firstName' does not exist */}
{/* <p>{user.firstName}</p> */}
<button onClick={handleUpdateName}>Update Name</button>
</div>
);
}
If you rename name to displayName in your Drizzle schema, TypeScript will immediately show errors in your tRPC router and every frontend component that references user.name. You can refactor your database schema and tsc --noEmit will tell you every single place in your entire application that needs to change.