DEV Community

Polliog
Polliog

Posted on

Building a Type-Safe Backend with tRPC, Zod, and Prisma

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Run the migration:

npx prisma migrate dev --name init
npx prisma generate
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode
// 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>;
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
        },
      });
    }),
});
Enter fullscreen mode Exit fullscreen mode
// 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,
      };
    }),
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Don't forget to install Fastify:

npm install fastify @fastify/cors @trpc/server
Enter fullscreen mode Exit fullscreen mode

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',
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode
<!-- 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}
Enter fullscreen mode Exit fullscreen mode

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...' });
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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),
}));
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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,
          },
        },
      },
    });
  }),
Enter fullscreen mode Exit fullscreen mode

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' }),
]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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)