Chapter 22

Full-Stack Type Safety: tRPC and Prisma/Drizzle ORM

The Problem: Frontend/Backend Type Drift

In a traditional full-stack project, the frontend and backend are separate codebases. Their type systems operate independently.

// Backend: Express + manually written interface
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

app.get('/api/users/:id', async (req, res) => {
  const user = await db.findUser(req.params.id);
  res.json(user);
});
// Frontend: defines its own copy of the same interface
interface User {
  id: string;
  name: string;
  email: string;
  // createdAt omitted — compiler has no idea
}

async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json() as User;  // assertion: assumes API response matches the type
}

Three months later the backend renames createdAt: Date to created_at: string (camelCase to snake_case). The backend is updated, but nobody touches the frontend interface because the compiler cannot check across an HTTP boundary. The problem surfaces in production.

This "frontend/backend type drift" is the most common hidden bug in full-stack TypeScript projects. There are three ways to solve it.

Three Approaches to Full-Stack Type Safety

Approach 1: OpenAPI + code generation

# Backend outputs openapi.json; frontend generates types from it
npx openapi-typescript openapi.json --output src/api.d.ts
# Or use orval to generate typed request functions
npx orval --input openapi.json --output src/api

Workflow: backend maintains an OpenAPI schema (written by hand or generated by decorators) → CI triggers generation → frontend consumes the generated types.

Pros: standardized, language-agnostic (Go backend + TypeScript frontend works fine).
Cons: extra generation step; generated type files are verbose; schema must be kept in sync with the implementation manually.

Approach 2: tRPC (zero code generation)

Types are shared directly through TypeScript's module system with no generation step. Requires full-stack TypeScript.

Approach 3: GraphQL + code generation

npx graphql-codegen

The GraphQL schema is the single source of truth; both sides generate types from it. More flexible than OpenAPI (clients query only the fields they need), but higher learning curve and heavier ecosystem.

Dimension OpenAPI + codegen tRPC GraphQL + codegen
Language constraint None (cross-language) Full-stack TypeScript required None (cross-language)
Code generation Required Not required Required
Type sync Triggered by CI Immediate — server change breaks client Triggered by CI
Learning curve Medium Low (know TS, know tRPC) High
Best for Existing REST API, multi-language teams Full-stack TS, fast iteration Complex queries, mobile clients

tRPC Core Concept

tRPC's key idea: the server defines procedures; the client uses them directly through the type system — no runtime bridge required.

npm install @trpc/server @trpc/client @trpc/react-query zod

Server: defining the router

// server/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
const t = initTRPC.create();

const router = t.router;
const publicProcedure = t.procedure;

// Auth middleware
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, userId: ctx.userId } });
});

export const appRouter = router({
  // Query (GET operations)
  getUser: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      // input.id type: string (UUID) — inferred from the zod schema
      const user = await prisma.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true, createdAt: true },
      });
      if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
      return user;
      // Return type inferred from the Prisma query automatically
    }),

  listUsers: publicProcedure
    .input(z.object({
      page: z.number().int().positive().default(1),
      pageSize: z.number().int().min(1).max(100).default(20),
      search: z.string().optional(),
    }))
    .query(async ({ input }) => {
      const { page, pageSize, search } = input;
      const where = search
        ? { OR: [{ name: { contains: search } }, { email: { contains: search } }] }
        : undefined;

      const [users, total] = await Promise.all([
        prisma.user.findMany({
          where,
          skip: (page - 1) * pageSize,
          take: pageSize,
          orderBy: { createdAt: 'desc' },
        }),
        prisma.user.count({ where }),
      ]);

      return { users, total, page, pageSize };
    }),

  // Mutation (POST/PUT/DELETE operations)
  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(1).max(100),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return prisma.user.create({ data: input });
    }),

  updateUser: protectedProcedure
    .input(z.object({
      id: z.string().uuid(),
      name: z.string().min(1).optional(),
      email: z.string().email().optional(),
    }))
    .mutation(async ({ input }) => {
      const { id, ...data } = input;
      return prisma.user.update({ where: { id }, data });
    }),

  deleteUser: protectedProcedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input }) => {
      await prisma.user.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

// Export the router type — this is the key to the magic
export type AppRouter = typeof appRouter;

Client: zero code generation, types used directly

// client/trpc.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';  // type-only import — no runtime code

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({ url: '/api/trpc' }),
  ],
});

async function example() {
  // Argument type: { id: string } — inferred from z.object({ id: z.string().uuid() })
  const user = await trpc.getUser.query({ id: '123e4567-e89b-12d3-a456-426614174000' });
  // user type: { id: string; name: string; email: string; createdAt: Date }
  // fully derived from the Prisma query + Prisma's generated types

  console.log(user.name.toUpperCase()); // string methods available
  console.log(user.createdAt.toISOString()); // Date methods available

  // Wrong type causes an immediate compile error
  // await trpc.getUser.query({ id: 123 }); // error: number not assignable to string
}

React with tRPC + TanStack Query

// client/trpc-react.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';

export const trpc = createTRPCReact<AppRouter>();

// App.tsx — set up providers
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';

const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

function App() {
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <UserList />
      </QueryClientProvider>
    </trpc.Provider>
  );
}

// UserList.tsx — consuming the API
function UserList() {
  // data type: { users: User[]; total: number; page: number; pageSize: number } | undefined
  const { data, isLoading, error } = trpc.listUsers.useQuery({
    page: 1,
    pageSize: 20,
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data?.users.map(user => (
        // user.name is string — type-safe
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// Mutations
function CreateUserForm() {
  const createUser = trpc.createUser.useMutation({
    onSuccess: (newUser) => {
      // newUser type: User — the full Prisma model
      console.log(`Created: ${newUser.id}`);
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.currentTarget;
    createUser.mutate({
      name: (form.elements.namedItem('name') as HTMLInputElement).value,
      email: (form.elements.namedItem('email') as HTMLInputElement).value,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

How type sharing works: import type { AppRouter } imports only TypeScript type information — zero runtime code. Bundlers erase this import completely before building the frontend bundle. Server code never appears in the client bundle.

Prisma: Schema-First, Auto-Generated Types

Prisma's workflow: write schema.prisma → run prisma generate → get complete types.

// schema.prisma
model Post {
  id        String   @id @default(uuid())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  tags      Tag[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Tag {
  id    String @id @default(uuid())
  name  String @unique
  posts Post[]
}

Return type changes based on select/include

import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

// Basic query — returns the full Post object
const post = await prisma.post.findUnique({ where: { id } });
// type: Post | null

// select — returns only specified fields
const postTitle = await prisma.post.findUnique({
  where: { id },
  select: { id: true, title: true },
});
// type: { id: string; title: string } | null

// include — adds related data
const postWithAuthor = await prisma.post.findUnique({
  where: { id },
  include: { author: true },
});
// type: (Post & { author: User }) | null

// Nested select/include
const postWithTags = await prisma.post.findUnique({
  where: { id },
  include: {
    tags: { select: { name: true } },
    author: { select: { id: true, name: true } },
  },
});
// type: (Post & { tags: { name: string }[]; author: { id: string; name: string } }) | null

Prisma.XxxGetPayload: extracting complex query types

// Use GetPayload when you need to pass a query result type to other functions
type PostWithRelations = Prisma.PostGetPayload<{
  include: {
    author: { select: { id: true; name: true } };
    tags: { select: { name: true } };
  };
}>;

// Equivalent to:
// Post & {
//   author: { id: string; name: string };
//   tags: { name: string }[];
// }

function formatPost(post: PostWithRelations): string {
  return `${post.title} by ${post.author.name} [${post.tags.map(t => t.name).join(', ')}]`;
}

Prisma transactions — fully typed

const result = await prisma.$transaction(async (tx) => {
  // tx has the same type as prisma, but scoped to the transaction
  const user = await tx.user.create({
    data: { name: 'Alice', email: '[email protected]' },
  });
  const post = await tx.post.create({
    data: {
      title: 'Hello',
      authorId: user.id,  // user.id is string — type-safe
    },
  });
  return { user, post };
});
// result type: { user: User; post: Post }

Drizzle: Code-First, Tighter TypeScript Integration

Drizzle defines schemas in TypeScript files — no separate schema language.

npm install drizzle-orm @drizzle-kit pg
// schema.ts — define the schema in TypeScript
import { pgTable, text, integer, boolean, timestamp, uuid } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  age: integer('age').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const posts = pgTable('posts', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: text('title').notNull(),
  content: text('content'),
  published: boolean('published').default(false).notNull(),
  authorId: uuid('author_id').references(() => users.id).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
// db.ts — using the schema
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
import { eq, like, desc } from 'drizzle-orm';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool, { schema });

// Query: type inferred from the schema automatically
const user = await db.select().from(schema.users).where(eq(schema.users.id, id)).limit(1);
// type: { id: string; name: string; email: string; age: number; createdAt: Date }[]

// Type-safe insert
const newUser = await db.insert(schema.users).values({
  name: 'Alice',
  email: '[email protected]',
  age: 30,
  // Passing a field that doesn't exist in the schema is a compile error
}).returning();
// newUser type: { id: string; name: string; email: string; age: number; createdAt: Date }[]

// Relational query
const userWithPosts = await db.query.users.findFirst({
  where: eq(schema.users.id, id),
  with: { posts: true },
});
// type: { id: string; name: string; ...; posts: Post[] } | undefined

Drizzle type utilities

import { InferSelectModel, InferInsertModel } from 'drizzle-orm';

// Extract the Select type (query result)
type User = InferSelectModel<typeof schema.users>;
// { id: string; name: string; email: string; age: number; createdAt: Date }

// Extract the Insert type (insert argument; id/createdAt are optional)
type NewUser = InferInsertModel<typeof schema.users>;
// { name: string; email: string; age: number; id?: string; createdAt?: Date }

Prisma vs Drizzle vs TypeORM

Dimension Prisma Drizzle TypeORM
Schema definition Separate .prisma file TypeScript code Decorators + classes
Type inference precision Very high (select narrows return type) Very high Medium (always returns full entity)
Migration tooling Built-in (Prisma Migrate) Built-in (drizzle-kit) Built-in
Raw SQL $queryRaw + template literal sql tagged template query()
Bundle size Larger (query engine binary) Small (pure JS) Medium
Relations include / relation API with / join @Relation() decorator
Learning curve Low (simple DSL) Medium (requires SQL understanding) High (decorators + complex config)
Best for Fast dev, schema-driven Fine-grained SQL, bundle-sensitive Devs with Java/C# ORM background

TypeORM has the weakest type safety of the three: relation queries return Promise<Entity> rather than precise types; find always returns the full entity (no narrowing by select); heavy use of any and type assertions throughout. New projects rarely choose TypeORM.

Anti-Patterns

Anti-pattern Problem Correct approach
Redefining backend interfaces on the frontend The root cause of type drift Share types via tRPC's AppRouter or OpenAPI codegen
res.json() as User assertion API can return anything without a type error tRPC guarantees types; or validate the response with zod
Manually writing interfaces that duplicate Prisma query results Interfaces drift from actual results Use Prisma.UserGetPayload<typeof query>
Not adding .input() validation on tRPC procedures Accepts arbitrary input; may crash at runtime Always define input types with a zod schema
Using TypeORM for new projects Weak type safety, high complexity Prefer Prisma or Drizzle

Summary

Approach When to choose
tRPC Full-stack TypeScript, same monorepo, fast iteration
OpenAPI + codegen Existing REST API, multi-language team, documentation needed
GraphQL + codegen Complex query requirements, mobile clients, multiple frontends
Prisma Schema-first, rapid development, strong migration tooling
Drizzle Fine-grained SQL control, bundle size matters, code-first

Next chapter covers testing TypeScript code: runtime tests (typed mocks in vitest) and type-level tests (verifying type correctness with tsd), plus how to integrate type errors into CI as test failures.

Rate this chapter
4.5  / 5  (7 ratings)

💬 Comments