NestJS
How to use ts-rest with NestJS
NestJS integration with ts-rest provides type-safe API endpoints while maintaining the familiar NestJS decorator-based architecture. We offer two approaches to fit different development styles and migration strategies.
Installation
pnpm add @ts-rest/nest
bun add @ts-rest/nest
npm install @ts-rest/nest
Approaches
NestJS has always been a tricky framework to integrate with ts-rest due to how Nest handles routing and controllers. Rather than requiring you to abandon Nest's decorator approach, we provide two flexible patterns that work alongside your existing Nest architecture.
Single Handler Approach - Easy Migration and Flexibility
The single handler approach provides a 1:1 migration strategy for existing controllers. You can replace individual Nest routes with ts-rest routes without affecting other routes in the same controller. This is perfect for gradual adoption or when you want different endpoints across multiple controllers.
@Controller()
export class PostController {
constructor(private readonly postService: PostService) {}
@TsRestHandler(contract.getPost)
async getPost() {
return tsRestHandler(contract.getPost, async ({ params }) => {
const post = await this.postService.getPost(params.id);
if (!post) {
return { status: 404, body: { message: 'Post not found' } };
}
return { status: 200, body: post };
});
}
@TsRestHandler(contract.getPosts)
async getPosts() {
return tsRestHandler(contract.getPosts, async ({ query }) => {
const posts = await this.postService.getPosts();
return { status: 200, body: posts };
});
}
}
The method names don't matter in ts-rest controllers, just like regular Nest endpoints. Name them whatever makes sense for your codebase.
Why do we return tsRestHandler
? This function simply returns its second argument, but provides full TypeScript intellisense and type safety for the implementation. This keeps the API consistent with other ts-rest server implementations like @ts-rest/express
and @ts-rest/fastify
.
Benefits
- Easy migration: Replace one route at a time without touching others
- Flexible architecture: Implement one contract across multiple controllers based on your domain
- Gradual adoption: Perfect for existing codebases
- Freedom of organization: No requirement for 1:1 contract-to-controller mapping
Multi Handler Approach - Ultimate Type Safety
The multi handler approach is ideal for those who prefer functional programming patterns or want compile-time guarantees that every route in a contract is implemented. TypeScript will error if you forget to implement any route.
@Controller()
export class PostController {
constructor(private readonly postService: PostService) {}
@TsRestHandler(contract)
async handler() {
return tsRestHandler(contract, {
getPost: async ({ params }) => {
const post = await this.postService.getPost(params.id);
if (!post) {
return { status: 404, body: { message: 'Post not found' } };
}
return { status: 200, body: post };
},
getPosts: async ({ query }) => {
const posts = await this.postService.getPosts();
return { status: 200, body: posts };
},
});
}
}
Pass the entire contract (or a subset) to both @TsRestHandler
and tsRestHandler
. TypeScript will enforce that you implement every route defined in the contract.
Benefits
- Compile-time safety: TypeScript errors if you miss implementing any route
- Less boilerplate: Single decorator and handler for multiple routes
- Consistency: Easier to move code between
@ts-rest/express
,@ts-rest/fastify
, and@ts-rest/next
- Functional approach: Great for those who prefer this programming style
Using Nest Decorators
You can still use all existing Nest decorators alongside @TsRestHandler
. This gives you access to the underlying request/response objects and Nest's dependency injection system.
@Controller()
export class UserController {
@TsRestHandler(contract.getProfile)
@UseGuards(AuthGuard)
async getProfile(
@Req() req: AuthenticatedRequest,
@Headers('authorization') auth: string,
) {
return tsRestHandler(contract.getProfile, async ({ headers }) => {
// You can use both ts-rest typed data and Nest decorators
console.log('Auth header from ts-rest:', headers.authorization);
console.log('Auth header from Nest:', auth);
console.log('User from request:', req.user);
return {
status: 200,
body: { id: req.user.id, name: req.user.name },
};
});
}
}
This isn't limited to parameter decorators - you can use guards, interceptors, pipes, and any other Nest decorators as you normally would.
Type-Safe Error Handling
While we recommend returning errors as responses to maintain full type safety in your contract, sometimes throwing exceptions is cleaner. TsRestException
lets you throw type-safe exceptions that ts-rest will catch and convert to properly typed responses.
class UserService {
async createUser(data: { email: string; name: string }) {
const user = await db.getUserByEmail(data.email);
if (user) {
throw new TsRestException(contract.createUser, {
status: 409,
body: {
code: 'UserAlreadyExists',
message: 'User with this email already exists',
},
});
}
return {
id: '1',
email: data.email,
name: data.name,
};
}
}
@Controller()
export class UserController {
constructor(private readonly userService: UserService) {}
@TsRestHandler(contract.createUser)
async createUser() {
return tsRestHandler(contract.createUser, async ({ body }) => {
const user = await this.userService.createUser(body);
return { status: 201, body: user };
});
}
}
TsRestException
provides full autocomplete for valid status codes and response bodies defined in your contract.
Caution: Be careful when throwing exceptions from shared code used by multiple routes. You might throw the wrong exception type for a given route. For maximum safety, return responses directly from the handler.
Configuration
Configure ts-rest options using the @TsRest
decorator on controllers or the @TsRestHandler
decorator on methods. Controller options apply to all routes and override global options. Method options override controller options for that specific route.
@Controller()
@TsRest({ jsonQuery: true }) // Applied to all routes in this controller
export class SearchController {
@TsRestHandler(contract.search)
async search() {
return tsRestHandler(contract.search, async ({ query }) => {
// query.filters is properly parsed as an object due to jsonQuery: true
console.log(query.filters.category);
return { status: 200, body: { results: [] } };
});
}
@TsRestHandler(contract.getItem, { jsonQuery: false }) // Override for this route
async getItem() {
return tsRestHandler(contract.getItem, async ({ query, params }) => {
// query.include is a string array due to normal query parsing
return { status: 200, body: { item: {} } };
});
}
}
For global configuration options and more details, check the configuration documentation.
Important Considerations
Path Prefixes
Known Limitation: Nest's global prefixes, versioning, and controller prefixes are currently ignored by ts-rest. See GitHub issue #70 for details.
Workaround: Use ts-rest's path prefix feature in your contract definition to achieve similar functionality.