Skip to content

Building Type-Safe APIs with NestJS and Prisma

March 15, 2026

4 min read

Building Type-Safe APIs with NestJS and Prisma
0:00
Ready

Building APIs that don't break is hard. Building APIs that tell you exactly what's wrong before they break? That's the dream. In this post, I'll walk you through how I structure my NestJS + Prisma projects to achieve end-to-end type safety—from database schema to API response.

This isn't just theory. These patterns come from building production systems like JED, an event management platform where a type error in the voting system could mean chaos.

Why Type Safety Matters

Here's a scenario: you rename a database column from userId to creatorId. In a loosely-typed codebase, you deploy, and suddenly your API is returning undefined for every user reference. In a type-safe codebase, your build fails immediately, pointing to every place that needs updating.

Type safety isn't about being pedantic—it's about sleeping well at night.

The Stack

  • NestJS — Structured, opinionated Node.js framework
  • Prisma — Type-safe ORM with auto-generated types
  • TypeScript — The glue that holds it all together
  • Zod — Runtime validation that mirrors your types

Step 1: Define Your Prisma Schema

Everything starts with your data model. Prisma schemas are declarative and generate TypeScript types automatically.

// prisma/schema.prisma
model Event {
id String @id @default(cuid())
title String
description String?
startDate DateTime
endDate DateTime
creatorId String
creator User @relation(fields: [creatorId], references: [id])
votes Vote[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model User {
id String @id @default(cuid())
email String @unique
name String
events Event[]
votes Vote[]
createdAt DateTime @default(now())
}

model Vote {
id String @id @default(cuid())
eventId String
event Event @relation(fields: [eventId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
choice String
createdAt DateTime @default(now())

@@unique([eventId, userId]) // One vote per user per event
}

After running npx prisma generate, you get fully typed client methods:

// This is fully typed - TypeScript knows exactly what fields exist
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { creator: true, votes: true }
});

// event.creator.name is typed as string
// event.votes is typed as Vote[]

Step 2: Create DTOs with Validation

DTOs (Data Transfer Objects) define what your API accepts. I use class-validator for decorators and Zod for complex validation.

// src/events/dto/create-event.dto.ts
import { IsString, IsDateString, IsOptional, MinLength } from 'class-validator';

export class CreateEventDto {
@IsString()
@MinLength(3, { message: 'Title must be at least 3 characters' })
title: string;

@IsString()
@IsOptional()
description?: string;

@IsDateString()
startDate: string;

@IsDateString()
endDate: string;
}

Enable global validation in your main.ts:

// src/main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw on unknown properties
transform: true, // Auto-transform payloads to DTO instances
}));

await app.listen(3000);
}

Now invalid requests get rejected before they reach your business logic:

// POST /events with invalid body
{
"title": "Hi",
"startDate": "not-a-date"
}

// Response: 400 Bad Request
{
"statusCode": 400,
"message": [
"Title must be at least 3 characters",
"startDate must be a valid ISO 8601 date string"
],
"error": "Bad Request"
}

Step 3: Type Your Service Layer

Your service layer should use Prisma's generated types. Here's where the magic happens:

// src/events/events.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEventDto } from './dto/create-event.dto';
import { Prisma } from '@prisma/client';

@Injectable()
export class EventsService {
constructor(private prisma: PrismaService) {}

async create(dto: CreateEventDto, userId: string) {
// Prisma.EventCreateInput is auto-generated from your schema
const data: Prisma.EventCreateInput = {
title: dto.title,
description: dto.description,
startDate: new Date(dto.startDate),
endDate: new Date(dto.endDate),
creator: { connect: { id: userId } },
};

return this.prisma.event.create({
data,
include: { creator: true }
});
}

async findOne(id: string) {
const event = await this.prisma.event.findUnique({
where: { id },
include: {
creator: true,
votes: { include: { user: true } }
},
});

if (!event) {
throw new NotFoundException(`Event with ID ${id} not found`);
}

return event;
}

async vote(eventId: string, userId: string, choice: string) {
// Prisma enforces the unique constraint - duplicate votes throw
return this.prisma.vote.create({
data: {
event: { connect: { id: eventId } },
user: { connect: { id: userId } },
choice,
},
});
}
}

Step 4: Response Types with Transformations

Sometimes you need to transform data before sending it. Use class-transformer to control what gets serialized:

// src/events/entities/event.entity.ts
import { Exclude, Expose, Type } from 'class-transformer';

export class UserEntity {
id: string;
name: string;
email: string;

@Exclude() // Never expose this
password?: string;
}

export class EventEntity {
id: string;
title: string;
description: string | null;
startDate: Date;
endDate: Date;

@Type(() => UserEntity)
creator: UserEntity;

@Expose() // Computed property
get isActive(): boolean {
const now = new Date();
return now >= this.startDate && now <= this.endDate;
}

constructor(partial: Partial<EventEntity>) {
Object.assign(this, partial);
}
}

Use it in your controller:

// src/events/events.controller.ts
@Controller('events')
export class EventsController {
constructor(private eventsService: EventsService) {}

@Get(':id')
async findOne(@Param('id') id: string) {
const event = await this.eventsService.findOne(id);
return new EventEntity(event); // Applies transformations
}
}

Step 5: Error Handling That Makes Sense

Prisma throws specific error codes. Catch them to return meaningful responses:

// src/common/filters/prisma-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';

@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();

let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Database error';

switch (exception.code) {
case 'P2002': // Unique constraint violation
status = HttpStatus.CONFLICT;
message = 'A record with this value already exists';
break;
case 'P2025': // Record not found
status = HttpStatus.NOT_FOUND;
message = 'Record not found';
break;
case 'P2003': // Foreign key constraint failed
status = HttpStatus.BAD_REQUEST;
message = 'Related record does not exist';
break;
}

response.status(status).json({
statusCode: status,
message,
error: exception.code,
});
}
}

The Full Picture

When everything connects, you get a flow like this:

  1. Request comes in → ValidationPipe validates against DTO
  2. DTO passes → Controller calls service with typed parameters
  3. Service executes → Prisma provides typed queries and results
  4. Response transforms → Entity classes control serialization
  5. Errors handled → Custom filters return consistent error formats

At every step, TypeScript is watching. Rename a field? The compiler tells you everywhere it's used. Add a required property? Every usage site lights up red until you handle it.

Key Takeaways

  1. Start with your schema: Prisma generates types from your database model
  2. Validate at the edge: DTOs catch bad data before it reaches business logic
  3. Trust your types: Avoid any like the plague
  4. Transform responses: Control exactly what leaves your API
  5. Handle errors consistently: Custom filters for Prisma errors

Type safety isn't a luxury, it's insurance. The hour you spend setting up proper types saves you days of debugging mysterious runtime errors in production.


Building something with NestJS + Prisma? I'd love to hear about it. Reach out (opens in new tab) or book a call (opens in new tab) to chat about your architecture.

React to this post:
0

Comments

?