@ivancerovina/contracts-nestjs
v1.0.0
Published
NestJS integration for @ivancerovina/contracts — route binding, Zod validation, response envelope, error handling
Maintainers
Readme
@ivancerovina/contracts-nestjs
NestJS integration for @ivancerovina/contracts. Binds contract routes to controller methods — sets HTTP method, path, status code, Zod validation, response envelope wrapping, and error handling from a single decorator.
Install
pnpm add @ivancerovina/contracts-nestjs @ivancerovina/contractsPeer dependencies: @nestjs/common >= 10, @nestjs/core >= 10.
Usage
Use @Controller() as normal. Decorate each method with @BindContract(contract, "routeName") — the route name autocompletes from the contract's routes.
import { Controller, Param, Query, Body } from "@nestjs/common";
import { BindContract, ContractError } from "@ivancerovina/contracts-nestjs";
import { TaskContract } from "./task.contract";
@Controller(TaskContract.baseRoute)
export class TaskController {
constructor(private readonly tasks: TaskService) {}
@BindContract(TaskContract, "listTasks")
async listTasks(@Query() query: { page: number; limit: number; status?: string }) {
return this.tasks.findAll(query);
}
@BindContract(TaskContract, "getTask")
async getTask(@Param("taskId") taskId: string) {
const task = await this.tasks.findById(taskId);
if (!task) throw new ContractError("TASK_NOT_FOUND", "Task not found", 404);
return task;
}
@BindContract(TaskContract, "createTask")
async createTask(@Body() body: { title: string; projectId: string }) {
return this.tasks.create(body);
}
}Register the controller normally with @Module({ controllers: [TaskController] }).
What @BindContract does
For each decorated method, the decorator:
| Concern | What it sets |
|---------|-------------|
| HTTP method | GET, POST, PATCH, PUT, DELETE from routeDef.method |
| Path | routeDef.path (relative to @Controller prefix) |
| Status code | 201 for POST, 200 for everything else |
| Validation | Runs routeDef.params, routeDef.query, routeDef.body through Zod .safeParse() before the handler executes. Mutates req.params/req.query/req.body with parsed values so @Param(), @Query(), @Body() receive validated data. |
| Response envelope | Attaches ContractInterceptor — wraps return values in { success: true, data, requestId } |
| Error handling | Attaches ContractErrorFilter — catches ContractError and returns { success: false, error: { message, code, details? }, requestId } |
You still use standard NestJS decorators (@Param(), @Query(), @Body(), @UseGuards(), etc.) — @BindContract composes with them.
ContractError
Throw from handlers or services to produce typed error responses.
throw new ContractError(code, message, status);
throw new ContractError(code, message, status, zodError);| Param | Type | Description |
|-------|------|-------------|
| code | string | Error code from the contract's errors (e.g., "TASK_NOT_FOUND") |
| message | string | Human-readable message |
| status | number | HTTP status code |
| validationErrors | ZodError? | Optional — attached automatically for validation failures |
Error response
{
"success": false,
"error": {
"message": "Task not found",
"code": "TASK_NOT_FOUND"
},
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}Validation errors include field-level details:
{
"success": false,
"error": {
"message": "Invalid request body",
"code": "BAD_REQUEST",
"details": [
{ "path": ["title"], "message": "String must contain at least 1 character(s)" }
]
},
"requestId": "..."
}Success response
The ContractInterceptor wraps handler return values automatically:
{
"success": true,
"data": { "id": "...", "title": "...", "status": "todo" },
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}requestId is resolved from req.id (set by logging middleware), x-request-id header, or a generated UUID.
Type helpers
InputFor is a shorthand for typing handler parameters when you want the full parsed input object:
import type { InputFor } from "@ivancerovina/contracts-nestjs";
type CreateTaskInput = InputFor<typeof TaskContract, "createTask">;
// { body: { title: string; projectId: string; ... } }
type GetTaskInput = InputFor<typeof TaskContract, "getTask">;
// { params: { taskId: string } }RouteInput<R> does the same for a single route definition.
Exports
| Export | Description |
|--------|-------------|
| BindContract(contract, routeName) | Method decorator — wires routing, validation, envelope, error filter |
| ContractError | Throwable error class with code, message, status, validationErrors? |
| ContractInterceptor | Response envelope interceptor (attached automatically by @BindContract) |
| ContractErrorFilter | Exception filter for ContractError (attached automatically by @BindContract) |
| InputFor<C, K> | Type helper — parsed input for a contract route |
| RouteInput<R> | Type helper — parsed input for a route definition |
Scripts
pnpm build # Build with tsdown
pnpm dev # Watch mode
pnpm lint # Biome check