5 min read

Type-Safe Full-Stack Development with tRPC, Drizzle ORM, and Zod

The trend toward end-to-end type safety from database to frontend is accelerating. Drizzle ORM has surged in popularity as a lightweight TypeScript-first ORM, and combined with tRPC and Zod validation, developers can build full-stack apps where types flow seamlessly across every layer.

Type-Safe Full-Stack Development with tRPC, Drizzle ORM, and Zod

If you’ve ever shipped a bug because your frontend expected a string but your API returned a number, you know the pain. If you’ve ever renamed a database column and then spent an hour tracking down every broken query scattered across your codebase, you really know the pain.

The promise of TypeScript full-stack development has always been this: one language, one type system, zero gaps. But for years, the reality fell short. You’d still hand-write API contracts, manually keep your frontend types in sync with your backend, and cross your fingers that your ORM’s types actually matched your database schema.

That’s changing fast. With tRPC, Drizzle ORM, and Zod working together, you can build applications where end-to-end types flow seamlessly from your database schema all the way to your React components — with zero code generation and zero runtime overhead. Let’s break down how.

The Three Pillars of End-to-End Type Safety

Before we dive into code, let’s understand what each tool brings to the table and why they’re so powerful together.

Drizzle ORM is a lightweight, TypeScript-first ORM that lets you define your database schema in plain TypeScript. Unlike heavier ORMs, Drizzle generates types directly from your schema definitions, so your queries are fully typed without a separate code generation step. It’s SQL-like API means you’re never fighting the abstraction — you’re just writing SQL with guardrails.

Zod is a schema validation library that lets you define runtime validation rules and infer TypeScript types from them. It bridges the gap between “what TypeScript thinks your data looks like” and “what your data actually looks like at runtime.”

tRPC eliminates the API layer as a source of type errors entirely. Instead of defining REST endpoints or GraphQL schemas, you write plain TypeScript functions on the server, and tRPC gives your client fully typed access to them — no code generation, no build step, no OpenAPI spec.

Together, these three form the backbone of what many developers know as the T3 stack (along with Next.js and Tailwind). But you don’t need the full T3 setup to benefit. The pattern works anywhere you have TypeScript on both ends.

Building the Stack: From Schema to Screen

Let’s build a practical example — a simple task management API — to see how type safety flows through every layer.

Step 1: Define Your Database Schema with Drizzle

// src/db/schema.ts
import { pgTable, text, boolean, timestamp, uuid } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";

export const tasks = pgTable("tasks", {
  id: uuid("id").defaultRandom().primaryKey(),
  title: text("title").notNull(),
  description: text("description"),
  completed: boolean("completed").default(false).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
});

// Drizzle-Zod bridges your DB schema to Zod validation schemas
export const insertTaskSchema = createInsertSchema(tasks, {
  title: z.string().min(1, "Title is required").max(256),
  description: z.string().max(1024).optional(),
});

export const selectTaskSchema = createSelectSchema(tasks);

// Infer TypeScript types directly — single source of truth
export type Task = z.infer<typeof selectTaskSchema>;
export type NewTask = z.infer<typeof insertTaskSchema>;

Notice what just happened. We defined our database table once, and from that single definition, we derived both our Zod validation schemas and our TypeScript types. There’s no separate types.ts file to keep in sync. Change a column, and everything updates.

Step 2: Create Your tRPC Router

// src/server/routers/tasks.ts
import { router, publicProcedure } from "../trpc";
import { db } from "../db";
import { tasks, insertTaskSchema } from "../db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";

export const taskRouter = router({
  // List all tasks — return type is inferred automatically
  list: publicProcedure.query(async () => {
    return await db.select().from(tasks).orderBy(tasks.createdAt);
  }),

  // Create a task — input is validated with the Zod schema we derived from Drizzle
  create: publicProcedure
    .input(insertTaskSchema.pick({ title: true, description: true }))
    .mutation(async ({ input }) => {
      const [newTask] = await db.insert(tasks).values(input).returning();
      return newTask;
    }),

  // Toggle completion status
  toggleComplete: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input }) => {
      const [existing] = await db
        .select()
        .from(tasks)
        .where(eq(tasks.id, input.id));

      if (!existing) throw new Error("Task not found");

      const [updated] = await db
        .update(tasks)
        .set({ completed: !existing.completed })
        .where(eq(tasks.id, input.id))
        .returning();

      return updated;
    }),
});

Here’s where the magic really shines. The insertTaskSchema.pick({ title: true, description: true }) call reuses the exact validation rules we derived from our database schema. If you change title’s max length in the schema, both your database constraints and your API validation update in lockstep.

Step 3: Consume It on the Frontend

// src/components/TaskList.tsx
import { trpc } from "../utils/trpc";
import { useState } from "react";

export function TaskList() {
  const [title, setTitle] = useState("");
  const utils = trpc.useUtils();

  // Fully typed — hover over `data` and you'll see Task[]
  const { data: taskList, isLoading } = trpc.tasks.list.useQuery();

  const createTask = trpc.tasks.create.useMutation({
    onSuccess: () => {
      utils.tasks.list.invalidate();
      setTitle("");
    },
  });

  const toggleTask = trpc.tasks.toggleComplete.useMutation({
    onSuccess: () => utils.tasks.list.invalidate(),
  });

  if (isLoading) return <p>Loading tasks...</p>;

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          createTask.mutate({ title }); // ✅ Type-checked against your DB schema
        }}
      >
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="New task..."
        />
        <button type="submit" disabled={createTask.isPending}>
          Add Task
        </button>
      </form>

      <ul>
        {taskList?.map((task) => (
          // ✅ task.id, task.title, task.completed — all fully typed
          <li key={task.id}>
            <label>
              <input
                type="checkbox"
                checked={task.completed}
                onChange={() => toggleTask.mutate({ id: task.id })}
              />
              <span style={{ textDecoration: task.completed ? "line-through" : "none" }}>
                {task.title}
              </span>
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

Try passing { titel: title } (a typo) to createTask.mutate() — TypeScript catches it instantly. Try accessing task.titl in the JSX — red squiggly, immediately. This isn’t just convenience; it’s a fundamentally different way of building full-stack apps.

Why This Matters More Than You Think

The end-to-end types pattern isn’t just about catching typos. It changes how you refactor. When your types flow from database to frontend, renaming a field becomes a compiler-assisted operation. You change it in the schema, and TypeScript shows you *every single place