Type safety is one of TypeScript's greatest strengths, but maintaining it across your entire stack—from database to API to frontend—can be challenging. In this article, I'll show you how to build a fully type-safe backend using tRPC, Zod, and Prisma, and demonstrate the power of this combination through practical examples.
Why This Stack?
Before diving into the implementation, let's understand why this combination is powerful:
- Prisma: Type-safe database access with auto-generated types from your schema
- Zod: Runtime validation with TypeScript type inference
- tRPC: End-to-end type safety without code generation, where your API types are automatically shared between client and server
The magic happens when these three work together: Prisma generates types from your database, Zod validates input at runtime while inferring TypeScript types, and tRPC ensures your frontend knows exactly what your backend expects and returns.
Project Setup
First, let's set up our project with the necessary dependencies:
npm init -y
npm install @trpc/server @trpc/client zod prisma @prisma/client
npm install -D typescript @types/node tsx
# Initialize Prisma
npx prisma init
Configure your tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Designing Your Database Schema
Let's create a practical example: a booking management system. This could apply to hotels, rental services, appointment scheduling, or any domain requiring reservation management. Here's our Prisma schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Guest {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bookings Booking[]
}
model Room {
id String @id @default(cuid())
name String
description String?
capacity Int
pricePerNight Decimal @db.Decimal(10, 2)
isAvailable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bookings Booking[]
}
model Booking {
id String @id @default(cuid())
guestId String
roomId String
checkIn DateTime
checkOut DateTime
totalPrice Decimal @db.Decimal(10, 2)
status BookingStatus @default(PENDING)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
guest Guest @relation(fields: [guestId], references: [id])
room Room @relation(fields: [roomId], references: [id])
@@index([guestId])
@@index([roomId])
@@index([checkIn, checkOut])
}
enum BookingStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
Run the migration:
npx prisma migrate dev --name init
npx prisma generate
Setting Up Zod Schemas
Zod will validate our inputs at runtime. Create validation schemas that complement your Prisma models:
// src/schemas/booking.schema.ts
import { z } from 'zod';
export const createBookingSchema = z.object({
guestId: z.string().cuid(),
roomId: z.string().cuid(),
checkIn: z.string().datetime().or(z.date()),
checkOut: z.string().datetime().or(z.date()),
notes: z.string().optional(),
}).refine(
(data) => new Date(data.checkOut) > new Date(data.checkIn),
{
message: "Check-out date must be after check-in date",
path: ["checkOut"],
}
);
export const updateBookingSchema = z.object({
id: z.string().cuid(),
status: z.enum(['PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED']).optional(),
notes: z.string().optional(),
});
export const getBookingsByDateRangeSchema = z.object({
startDate: z.string().datetime().or(z.date()),
endDate: z.string().datetime().or(z.date()),
roomId: z.string().cuid().optional(),
});
// Export inferred types
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
export type UpdateBookingInput = z.infer<typeof updateBookingSchema>;
export type GetBookingsByDateRangeInput = z.infer<typeof getBookingsByDateRangeSchema>;
// src/schemas/guest.schema.ts
import { z } from 'zod';
export const createGuestSchema = z.object({
email: z.string().email(),
firstName: z.string().min(1),
lastName: z.string().min(1),
phone: z.string().optional(),
});
export const updateGuestSchema = z.object({
id: z.string().cuid(),
email: z.string().email().optional(),
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
phone: z.string().optional(),
});
export type CreateGuestInput = z.infer<typeof createGuestSchema>;
export type UpdateGuestInput = z.infer<typeof updateGuestSchema>;
Creating the tRPC Context
The context is where we'll inject dependencies like our Prisma client:
// src/trpc/context.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
export interface Context {
prisma: PrismaClient;
}
export const createContext = (): Context => {
return {
prisma,
};
};
Initializing tRPC
Set up the base tRPC configuration:
// src/trpc/trpc.ts
import { initTRPC } from '@trpc/server';
import { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
Building Type-Safe Procedures
Now comes the powerful part—creating procedures that are fully type-safe:
// src/routers/booking.router.ts
import { router, publicProcedure } from '../trpc/trpc';
import { z } from 'zod';
import {
createBookingSchema,
updateBookingSchema,
getBookingsByDateRangeSchema,
} from '../schemas/booking.schema';
import { Decimal } from '@prisma/client/runtime/library';
export const bookingRouter = router({
create: publicProcedure
.input(createBookingSchema)
.mutation(async ({ ctx, input }) => {
const { checkIn, checkOut, roomId, guestId, notes } = input;
// Check room availability
const existingBookings = await ctx.prisma.booking.findMany({
where: {
roomId,
status: { in: ['PENDING', 'CONFIRMED'] },
OR: [
{
checkIn: { lte: new Date(checkOut) },
checkOut: { gte: new Date(checkIn) },
},
],
},
});
if (existingBookings.length > 0) {
throw new Error('Room is not available for the selected dates');
}
// Get room to calculate price
const room = await ctx.prisma.room.findUnique({
where: { id: roomId },
});
if (!room) {
throw new Error('Room not found');
}
// Calculate total price
const nights = Math.ceil(
(new Date(checkOut).getTime() - new Date(checkIn).getTime()) /
(1000 * 60 * 60 * 24)
);
const totalPrice = new Decimal(room.pricePerNight).times(nights);
// Create booking
return ctx.prisma.booking.create({
data: {
guestId,
roomId,
checkIn: new Date(checkIn),
checkOut: new Date(checkOut),
totalPrice,
notes,
status: 'PENDING',
},
include: {
guest: true,
room: true,
},
});
}),
update: publicProcedure
.input(updateBookingSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return ctx.prisma.booking.update({
where: { id },
data,
include: {
guest: true,
room: true,
},
});
}),
getById: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ ctx, input }) => {
const booking = await ctx.prisma.booking.findUnique({
where: { id: input.id },
include: {
guest: true,
room: true,
},
});
if (!booking) {
throw new Error('Booking not found');
}
return booking;
}),
getByDateRange: publicProcedure
.input(getBookingsByDateRangeSchema)
.query(async ({ ctx, input }) => {
const { startDate, endDate, roomId } = input;
return ctx.prisma.booking.findMany({
where: {
...(roomId && { roomId }),
OR: [
{
checkIn: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
{
checkOut: {
gte: new Date(startDate),
lte: new Date(endDate),
},
},
],
},
include: {
guest: true,
room: true,
},
orderBy: {
checkIn: 'asc',
},
});
}),
cancel: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.mutation(async ({ ctx, input }) => {
return ctx.prisma.booking.update({
where: { id: input.id },
data: { status: 'CANCELLED' },
include: {
guest: true,
room: true,
},
});
}),
});
// src/routers/guest.router.ts
import { router, publicProcedure } from '../trpc/trpc';
import { z } from 'zod';
import { createGuestSchema, updateGuestSchema } from '../schemas/guest.schema';
export const guestRouter = router({
create: publicProcedure
.input(createGuestSchema)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.guest.create({
data: input,
});
}),
update: publicProcedure
.input(updateGuestSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return ctx.prisma.guest.update({
where: { id },
data,
});
}),
getById: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ ctx, input }) => {
const guest = await ctx.prisma.guest.findUnique({
where: { id: input.id },
include: {
bookings: {
include: {
room: true,
},
orderBy: {
checkIn: 'desc',
},
},
},
});
if (!guest) {
throw new Error('Guest not found');
}
return guest;
}),
getByEmail: publicProcedure
.input(z.object({ email: z.string().email() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.guest.findUnique({
where: { email: input.email },
include: {
bookings: {
include: {
room: true,
},
},
},
});
}),
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().cuid().optional(),
})
)
.query(async ({ ctx, input }) => {
const { limit, cursor } = input;
const guests = await ctx.prisma.guest.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: {
createdAt: 'desc',
},
});
let nextCursor: string | undefined = undefined;
if (guests.length > limit) {
const nextItem = guests.pop();
nextCursor = nextItem?.id;
}
return {
guests,
nextCursor,
};
}),
});
Creating the App Router
Combine all routers into a single app router:
// src/routers/index.ts
import { router } from '../trpc/trpc';
import { bookingRouter } from './booking.router';
import { guestRouter } from './guest.router';
export const appRouter = router({
booking: bookingRouter,
guest: guestRouter,
});
export type AppRouter = typeof appRouter;
Setting Up the Server
Now let's create a server using Fastify (or Express if you prefer):
// src/server.ts
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { appRouter } from './routers';
import { createContext } from './trpc/context';
const server = Fastify({
logger: true,
maxParamLength: 5000,
});
// Enable CORS
server.register(cors, {
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
});
// Register tRPC
server.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext,
onError({ path, error }) {
console.error(`Error in tRPC handler on path '${path}':`, error);
},
},
});
const start = async () => {
try {
const port = Number(process.env.PORT) || 3000;
await server.listen({ port, host: '0.0.0.0' });
console.log(`🚀 Server ready at http://localhost:${port}`);
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start();
Don't forget to install Fastify:
npm install fastify @fastify/cors @trpc/server
Frontend Integration (SvelteKit Example)
Now for the magic—using your type-safe API in the frontend:
// src/lib/trpc.ts (in your SvelteKit app)
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../../backend/src/routers';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
<!-- src/routes/bookings/+page.svelte -->
<script lang="ts">
import { trpc } from '$lib/trpc';
import { onMount } from 'svelte';
let bookings: Awaited<ReturnType<typeof trpc.booking.getByDateRange.query>> = [];
let loading = true;
onMount(async () => {
try {
const today = new Date();
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
bookings = await trpc.booking.getByDateRange.query({
startDate: today.toISOString(),
endDate: nextMonth.toISOString(),
});
} catch (error) {
console.error('Failed to load bookings:', error);
} finally {
loading = false;
}
});
async function createBooking() {
try {
const newBooking = await trpc.booking.create.mutate({
guestId: 'clxxx...', // example ID
roomId: 'clyyy...', // example ID
checkIn: new Date('2024-03-15').toISOString(),
checkOut: new Date('2024-03-18').toISOString(),
notes: 'Early check-in requested',
});
console.log('Booking created:', newBooking);
// TypeScript knows the exact shape of newBooking!
} catch (error) {
console.error('Failed to create booking:', error);
}
}
</script>
{#if loading}
<p>Loading bookings...</p>
{:else}
<div class="bookings">
{#each bookings as booking}
<div class="booking-card">
<h3>{booking.guest.firstName} {booking.guest.lastName}</h3>
<p>Room: {booking.room.name}</p>
<p>Check-in: {new Date(booking.checkIn).toLocaleDateString()}</p>
<p>Check-out: {new Date(booking.checkOut).toLocaleDateString()}</p>
<p>Status: {booking.status}</p>
</div>
{/each}
</div>
{/if}
The Benefits You Get
1. Compile-Time Type Safety
If you try to call a procedure that doesn't exist or pass wrong parameters, TypeScript will catch it immediately:
// ❌ TypeScript error: Property 'nonExistent' does not exist
await trpc.booking.nonExistent.query();
// ❌ TypeScript error: Type 'number' is not assignable to type 'string'
await trpc.booking.getById.query({ id: 123 });
// ✅ Correct
await trpc.booking.getById.query({ id: 'clxxx...' });
2. Runtime Validation
Zod ensures that even if something bypasses TypeScript (like user input), your data is validated:
// If checkOut is before checkIn, Zod will throw an error at runtime
await trpc.booking.create.mutate({
guestId: 'clxxx...',
roomId: 'clyyy...',
checkIn: '2024-03-18',
checkOut: '2024-03-15', // ❌ Will fail validation
});
3. Auto-Complete Everywhere
Your IDE will provide intelligent autocomplete for all procedures, inputs, and return types:
const booking = await trpc.booking.getById.query({ id: 'clxxx...' });
// Your IDE knows all available properties:
booking.guest.firstName; // ✅
booking.room.name; // ✅
booking.totalPrice; // ✅
booking.nonExistent; // ❌ TypeScript error
4. Refactoring Safety
If you change your database schema or API, TypeScript will immediately show you everywhere in your frontend that needs updating. No more runtime surprises!
Advanced Patterns
Middleware for Authentication
// src/trpc/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
// Check if user is authenticated (example)
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
user: ctx.session.user,
},
});
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthed);
Input Transformers
export const createBookingSchema = z.object({
guestId: z.string().cuid(),
roomId: z.string().cuid(),
checkIn: z.string().datetime(),
checkOut: z.string().datetime(),
notes: z.string().optional(),
}).transform((data) => ({
...data,
checkIn: new Date(data.checkIn),
checkOut: new Date(data.checkOut),
}));
Optimistic Updates
// In your SvelteKit component
import { writable } from 'svelte/store';
const bookingsStore = writable<Booking[]>([]);
async function cancelBooking(bookingId: string) {
// Optimistic update
bookingsStore.update(bookings =>
bookings.map(b => b.id === bookingId
? { ...b, status: 'CANCELLED' }
: b
)
);
try {
await trpc.booking.cancel.mutate({ id: bookingId });
} catch (error) {
// Revert on error
const freshBookings = await trpc.booking.getByDateRange.query({
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
});
bookingsStore.set(freshBookings);
}
}
Testing Your Type-Safe API
// src/routers/__tests__/booking.test.ts
import { appRouter } from '../index';
import { createContext } from '../../trpc/context';
describe('Booking Router', () => {
const ctx = createContext();
const caller = appRouter.createCaller(ctx);
it('should create a booking', async () => {
const booking = await caller.booking.create({
guestId: 'test-guest-id',
roomId: 'test-room-id',
checkIn: new Date('2024-03-15').toISOString(),
checkOut: new Date('2024-03-18').toISOString(),
});
expect(booking).toHaveProperty('id');
expect(booking.status).toBe('PENDING');
});
it('should reject overlapping bookings', async () => {
await expect(
caller.booking.create({
guestId: 'test-guest-id',
roomId: 'test-room-id',
checkIn: new Date('2024-03-16').toISOString(),
checkOut: new Date('2024-03-17').toISOString(),
})
).rejects.toThrow('Room is not available');
});
});
Performance Considerations
Database Query Optimization
// Use Prisma's select to fetch only needed fields
getById: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.booking.findUnique({
where: { id: input.id },
select: {
id: true,
checkIn: true,
checkOut: true,
status: true,
guest: {
select: {
firstName: true,
lastName: true,
email: true,
},
},
room: {
select: {
name: true,
pricePerNight: true,
},
},
},
});
}),
Request Batching
tRPC automatically batches requests made within a short time window:
// These three requests will be batched into a single HTTP request
const [booking1, booking2, guest1] = await Promise.all([
trpc.booking.getById.query({ id: 'id1' }),
trpc.booking.getById.query({ id: 'id2' }),
trpc.guest.getById.query({ id: 'guest1' }),
]);
Deployment Considerations
Environment Variables
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/booking_db"
PORT=3000
NODE_ENV=production
FRONTEND_URL=https://your-frontend.com
Docker Setup
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY prisma ./prisma
RUN npx prisma generate
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Conclusion
Building a type-safe backend with tRPC, Zod, and Prisma provides incredible developer experience and confidence. You get:
- End-to-end type safety from database to frontend
- Runtime validation that catches errors before they reach your database
- Excellent IDE support with autocomplete and inline documentation
- Refactoring confidence knowing TypeScript will catch breaking changes
- No code generation needed—types are inferred automatically
The initial setup requires some learning, but the productivity gains and reduced bugs make it worthwhile for any serious TypeScript project. The combination of compile-time type checking with runtime validation gives you the best of both worlds.
This stack provides a solid foundation that scales well and keeps your codebase maintainable, whether you're building a booking system, an e-commerce platform, or any other API-driven application.
Resources
Have questions or want to share your experience with this stack? Drop a comment below!
Top comments (0)