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.