As a full-stack developer, type safety has become increasingly crucial in building robust and maintainable applications. In this comprehensive guide, we'll explore how to create a fully type-safe API using NestJS, Prisma, and TypeScript. By the end, you'll have a solid understanding of how to build APIs that catch errors at compile-time rather than runtime.
๐ฏ What We'll Cover
- Setting up a NestJS project with TypeScript
- Integrating Prisma with NestJS
- Creating type-safe database models
- Implementing CRUD operations with full type safety
- Best practices for error handling
- Advanced TypeScript features for better type safety
Prerequisites
- Node.js (v18 or later)
- Basic understanding of TypeScript
- Familiarity with RESTful APIs
- PostgreSQL installed locally
Initial Setup
First, let's create a new NestJS project with TypeScript:
npm i -g @nestjs/cli
nest new type-safe-api
cd type-safe-api
Now, let's add Prisma to our project:
npm install @prisma/client
npm install prisma --save-dev
npx prisma init
Defining Our Schema
Let's create a simple blog API with posts and categories. Here's our Prisma schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
category Category @relation(fields: [categoryId], references: [id])
categoryId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Category {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}
Creating Type-Safe DTOs
One of the key benefits of using NestJS with TypeScript is the ability to create type-safe DTOs:
// src/posts/dto/create-post.dto.ts
import { IsString, IsBoolean, IsNumber, IsOptional } from 'class-validator';
export class CreatePostDto {
@IsString()
title: string;
@IsString()
content: string;
@IsBoolean()
@IsOptional()
published?: boolean;
@IsNumber()
categoryId: number;
}
Implementing the Posts Service
Here's how we can implement a type-safe service layer:
// src/posts/posts.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Post, Prisma } from '@prisma/client';
import { CreatePostDto } from './dto/create-post.dto';
@Injectable()
export class PostsService {
constructor(private prisma: PrismaService) {}
async create(data: CreatePostDto): Promise<Post> {
return this.prisma.post.create({
data: {
...data,
published: data.published ?? false,
},
include: {
category: true,
},
});
}
async findOne(id: number): Promise<Post> {
const post = await this.prisma.post.findUnique({
where: { id },
include: {
category: true,
},
});
if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
return post;
}
}
Error Handling with Type Safety
Let's implement a custom error handling mechanism that leverages TypeScript's type system:
// src/common/errors/api-error.ts
export class ApiError extends Error {
constructor(
public readonly code: string,
public readonly message: string,
public readonly status: number = 400,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = 'ApiError';
}
static notFound(resource: string, id: number | string): ApiError {
return new ApiError(
'NOT_FOUND',
`${resource} with ID ${id} not found`,
404
);
}
}
Implementing Controllers with Type Safety
Here's how we can implement a type-safe controller:
// src/posts/posts.controller.ts
import { Controller, Get, Post, Body, Param, ParseIntPipe } from '@nestjs/common';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
create(@Body() createPostDto: CreatePostDto) {
return this.postsService.create(createPostDto);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.postsService.findOne(id);
}
}
Advanced Type Safety Features
1. Custom Type Guards
// src/common/guards/type-guards.ts
export function isPost(obj: unknown): obj is Post {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'title' in obj &&
'content' in obj
);
}
2. Generic Response Types
// src/common/types/api-response.type.ts
export interface ApiResponse<T> {
data: T;
meta?: {
count?: number;
page?: number;
totalPages?: number;
};
}
Testing Our Type-Safe API
Here's an example of how to write tests that leverage our type system:
// src/posts/posts.service.spec.ts
import { Test } from '@nestjs/testing';
import { PostsService } from './posts.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePostDto } from './dto/create-post.dto';
describe('PostsService', () => {
let service: PostsService;
let prisma: PrismaService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [PostsService, PrismaService],
}).compile();
service = module.get<PostsService>(PostsService);
prisma = module.get<PrismaService>(PrismaService);
});
it('should create a post', async () => {
const dto: CreatePostDto = {
title: 'Test Post',
content: 'Test Content',
categoryId: 1,
};
const created = await service.create(dto);
expect(created.title).toBe(dto.title);
});
});
Best Practices and Tips
- Always Use Strict TypeScript Configuration
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"strictPropertyInitialization": true
}
}
-
Leverage Prisma's Generated Types
- Use Prisma's generated types instead of creating your own interface definitions
- Take advantage of Prisma's type-safe query builders
Implement Custom Decorators for Common Validations
export function IsNonEmptyString() {
return applyDecorators(
IsString(),
IsNotEmpty(),
Transform(({ value }) => value?.trim())
);
}
- Use Enums for Type-Safe Constants
export enum PostStatus {
DRAFT = 'DRAFT',
PUBLISHED = 'PUBLISHED',
ARCHIVED = 'ARCHIVED'
}
Conclusion
Building type-safe APIs with NestJS, Prisma, and TypeScript provides several benefits:
- Catch errors at compile-time rather than runtime
- Improved developer experience with better IDE support
- Self-documenting code through type definitions
- Reduced need for runtime validation
- Better maintainability and refactoring capabilities
The combination of these tools creates a robust development environment where you can build APIs with confidence, knowing that many potential issues will be caught before they reach production.
Additional Resources
- NestJS Official Documentation
- Prisma Documentation
- TypeScript Handbook
- Class Validator Documentation
How are you using type safety in your NestJS applications? Share your experiences and best practices in the comments below!
Follow me for more articles on full-stack development, TypeScript, and software architecture.
๐ Happy coding! ๐
Top comments (0)