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:
Request comes in → ValidationPipe validates against DTO
DTO passes → Controller calls service with typed parameters
Service executes → Prisma provides typed queries and results
Response transforms → Entity classes control serialization
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
Start with your schema : Prisma generates types from your database model
Validate at the edge : DTOs catch bad data before it reaches business logic
Trust your types : Avoid any like the plague
Transform responses : Control exactly what leaves your API
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.