Building Type-Safe REST APIs with NestJS and Prisma

Photo by Unsplash

Photo by Unsplash
Runtime errors in production REST APIs are embarrassing and avoidable. At Commsult, we build internal ERP APIs that handle payroll approvals, invoice generation, and inventory movements — mistakes cost real money. The combination of NestJS (for structure), Prisma (for type-safe DB access), and class-validator (for request validation) forms a three-layer safety net that pushes errors to compile time and the test suite, not to production logs.
NestJS gives you Angular-style dependency injection and decorators that make controllers, services, and modules explicit and testable. Prisma generates a fully-typed client from your schema.prisma — every query result has TypeScript types, and impossible queries fail at compile time. class-validator with class-transformer validates incoming request bodies using decorators, so a missing required field or a negative price returns a 400 with a descriptive error message before your business logic even runs.
Understanding the NestJS request lifecycle is essential for placing logic correctly. Guards run first — they handle authentication and authorization. Pipes run next — this is where DTO validation happens via ValidationPipe. Then the controller method runs, delegates to the service, which calls Prisma. Finally, interceptors transform the response. Placing logic at the wrong layer means you lose the automatic 400 response and consistent error format.
Your Prisma schema.prisma is the canonical definition of your data model. From it, Prisma generates TypeScript types for every model, every relation, and every query result. When you change the schema — add a field, rename a column — running 'npx prisma generate' regenerates the client and TypeScript immediately flags any code that uses the old field name. This is the core type-safety loop: schema change → generate → TypeScript errors → fix before deploy.
HTTP Request
│
▼
┌─────────────────┐
│ NestJS Guard │ (JWT validation, role check)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Pipe / DTO │ (class-validator + class-transformer)
│ Validation │ (400 Bad Request on failure)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Controller │ (@Get, @Post, @Put, @Delete)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Service │ (business logic, type-safe)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Prisma Client │ (fully typed DB queries)
└────────┬────────┘
│
▼
PostgreSQL DBEnable Prisma's query logging in development: set log: ['query'] in PrismaClient options. You'll see the actual SQL being executed, which reveals N+1 query problems immediately. A service method that calls findMany on orders and then loops to find each customer individually becomes obvious — and painful — when you see 21 sequential SQL queries for 20 orders.
Data Transfer Objects (DTOs) are plain TypeScript classes decorated with class-validator decorators. They define exactly what shape a request body must have. NestJS's ValidationPipe intercepts incoming requests, runs class-validator on the body, and automatically returns 400 with field-level error messages if validation fails. Enable it globally in main.ts with app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })). The whitelist option strips unknown properties, preventing mass-assignment attacks.
Create a dedicated PrismaService that extends PrismaClient and implements OnModuleInit and OnModuleDestroy. This ensures the connection is opened once when the module initializes and closed gracefully on shutdown. Inject it via NestJS dependency injection wherever you need database access. Never instantiate PrismaClient directly in services — you'll exhaust your connection pool under load.
// prisma/schema.prisma
model Product {
id String @id @default(cuid())
name String
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryId String
category Category @relation(fields: [categoryId], references: [id])
}
// src/products/dto/create-product.dto.ts
import { IsString, IsNumber, IsPositive, IsNotEmpty, Min } from "class-validator"
import { Type } from "class-transformer"
export class CreateProductDto {
@IsString()
@IsNotEmpty()
name: string
@IsNumber({ maxDecimalPlaces: 2 })
@IsPositive()
@Type(() => Number)
price: number
@IsNumber()
@Min(0)
@Type(() => Number)
stock: number
@IsString()
@IsNotEmpty()
categoryId: string
}
// src/products/products.service.ts
import { Injectable, NotFoundException } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
import { CreateProductDto } from "./dto/create-product.dto"
import { Prisma } from "@prisma/client"
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async create(dto: CreateProductDto) {
return this.prisma.product.create({
data: dto,
include: { category: true },
})
}
async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit
const [data, total] = await this.prisma.$transaction([
this.prisma.product.findMany({
skip,
take: limit,
include: { category: { select: { name: true } } },
orderBy: { createdAt: "desc" },
}),
this.prisma.product.count(),
])
return { data, total, page, limit, pages: Math.ceil(total / limit) }
}
async findOne(id: string) {
const product = await this.prisma.product.findUnique({
where: { id },
include: { category: true },
})
if (!product) throw new NotFoundException(`Product ${id} not found`)
return product
}
}Prisma throws typed errors: PrismaClientKnownRequestError with code P2025 means 'record not found', P2002 means 'unique constraint violation'. Catch these in your service and throw the appropriate NestJS exception (NotFoundException, ConflictException). Use an exception filter to standardize error responses: every error should return { statusCode, message, error, timestamp, path }. Consistent error shapes make frontend error handling trivial.
Prisma error messages can contain table names, column names, and query details that reveal your database schema. Always catch Prisma errors in your service layer, log the full error server-side, and throw a sanitized NestJS HTTP exception to the client. A raw PrismaClientKnownRequestError in a 500 response is a security issue, not just an UX problem.
The type-safety stack shines in testing. Use @nestjs/testing to create a testing module, mock PrismaService with jest.mock, and test each service method in isolation. Because Prisma types are generated from your schema, your mocked return values must match the real types — TypeScript catches wrong mock shapes at compile time. For e2e tests, use the Prisma test environment with database transactions that roll back after each test.
As the API grows, introduce a repository pattern between the service and Prisma. The repository handles raw Prisma queries; the service handles business logic. This separation makes services easier to test and lets you swap the database layer without rewriting business logic. For complex queries with many relations, use Prisma's select and include sparingly — define reusable select objects as TypeScript constants to avoid duplicating field lists across the codebase.