Skip to main content
BlogWeb Dev

Building Type-Safe REST APIs with NestJS and Prisma

How to wire NestJS, Prisma, and class-validator together so TypeScript type errors catch bugs before they reach production.

Matthews Wong

Software Engineer

June 202510 min read
Building Type-Safe REST APIs with NestJS and Prisma

Photo by Unsplash

Runtime errors in production REST APIs are embarrassing and avoidable. ERP APIs that handle payroll approvals, invoice generation, and inventory movements are unforgiving — 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.

Why This Stack?

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.

The Request Lifecycle

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.

Prisma Schema as the Source of Truth

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 DB

Enable 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.

DTOs, Validation, and the ValidationPipe

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.

Prisma Service as a Singleton

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
  }
}

Error Handling and Consistent Response Shapes

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.

Testing Type-Safe APIs

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.

Scaling This Pattern to Larger APIs

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.

Sources & Further Reading

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why use Prisma instead of a raw query builder or plain SQL in a NestJS API?

Prisma generates a fully-typed client directly from your schema.prisma, so every query result carries TypeScript types and impossible queries fail at compile time rather than at runtime. When you rename a column or add a field, running 'npx prisma generate' immediately surfaces any code that references the old name, closing the feedback loop before deployment. This schema-as-source-of-truth approach is especially valuable in ERP APIs where a stale field name could corrupt payroll or invoice data.

Where in the NestJS request lifecycle should DTO validation be placed, and why does placement matter?

DTO validation belongs in the Pipe layer, which runs after Guards but before the controller method executes. Placing ValidationPipe globally in main.ts ensures every incoming request body is checked for missing required fields or invalid values, returning a descriptive 400 response before any business logic runs. Putting validation logic inside the service or controller instead means losing the automatic 400 format and consistent error shape.

How does enabling 'whitelist: true' on ValidationPipe protect against mass-assignment attacks?

The whitelist option instructs ValidationPipe to strip any properties from the request body that are not declared on the DTO class, so an attacker cannot inject extra fields that map to sensitive database columns. Combining it with 'forbidNonWhitelisted: true' goes further and returns a 400 error if unknown properties are present, making the rejection explicit rather than silent.

Why should Prisma errors never be sent directly to API clients?

Prisma error messages can expose internal details such as table names, column names, and raw query fragments, which reveal your database schema to potential attackers. The recommended pattern is to catch PrismaClientKnownRequestError in the service layer, log the full error server-side, and re-throw a sanitized NestJS HTTP exception — for example NotFoundException for P2025 or ConflictException for P2002. Exposing a raw Prisma error in a 500 response is a security issue, not merely a UX problem.

How does the repository pattern help when a NestJS + Prisma API grows larger?

Introducing a repository layer between the service and Prisma separates raw database queries from business logic, making services easier to unit-test and allowing the database layer to be swapped without rewriting business rules. The post also recommends defining reusable Prisma select objects as TypeScript constants to avoid duplicating long field lists across the codebase as the number of relations and endpoints grows.