Skip to content
rest-rpc
Esc
navigateopen⌘Jpreview
On this page

NestJS

Use rest-rpc with NestJS

Install

pnpm add @rest-rpc/nest

Usage

The Nest adapter lets a contract router map to a normal Nest provider class. The controller declares the Nest route boundary, while the provider implements the contract.

import { Controller, Injectable, Module } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import {
	RestRpcModule,
	RouteRequest,
	RouteHandlers,
	Router,
	router,
} from "@rest-rpc/nest";
import type { Request } from "express";
import { api } from "./contract";

@Injectable()
class TodoRepository {
	findForUser(id: string, userId: string) {
		return { id, title: "Write docs", userId };
	}

	createForUser(input: { title: string; userId: string }) {
		return { id: "todo-1", title: input.title, userId: input.userId };
	}
}

@Injectable()
class TodoService implements RouteHandlers<typeof api.todos> {
	constructor(private readonly todos: TodoRepository) {}

	get({ id, context }: RouteRequest<typeof api.todos.get>) {
		return this.todos.findForUser(id, context.userId);
	}

	create({ title, context }: RouteRequest<typeof api.todos.create>) {
		return {
			status: 201 as const,
			body: this.todos.createForUser({ title, userId: context.userId }),
		};
	}
}

@Controller()
class TodoController {
	constructor(private readonly todos: TodoService) {}

	@Router(api.todos)
	todosRouter() {
		return router(api.todos, this.todos);
	}
}

declare module "@rest-rpc/nest" {
	interface DefaultNestContext {
		userId: string;
		request: Request;
	}
}

@Module({
	imports: [
		RestRpcModule.forRoot({
			createContext: (context) => {
				const req = context.switchToHttp().getRequest<Request>();
				return {
					userId: req.header("x-user-id") ?? "",
					request: req,
				};
			},
		}),
	],
	controllers: [TodoController],
	providers: [TodoRepository, TodoService],
})

class AppModule {}

const app = await NestFactory.create(AppModule);
await app.listen(3000);

@Router() belongs on the controller because it tells Nest which HTTP routes to register. router() connects the contract router to the provider instance that implements it.

The provider is a first-class Nest provider. It can use constructor injection, module ownership, scopes, lifecycle hooks, and testing overrides like any other service.

Single Routes

Use @Route() when one endpoint needs custom glue, or when existing providers do not match the contract shape.

import { Controller } from "@nestjs/common";
import { Route, route } from "@rest-rpc/nest";
import { api } from "./contract";

@Controller()
class TodoController {
	constructor(private readonly todos: TodoRepository) {}

	@Route(api.todos.get)
	getTodo() {
		return route(api.todos.get, ({ id, context }) => {
			return this.todos.findForUser(id, context.userId);
		});
	}
}

Inline Routers

You can also define the handlers directly in the controller. This is useful when your providers expose their own application API instead of implementing RouteHandlers. The controller maps the typed contract request and values from Nest decorators into calls to those providers.

import { Controller, Headers, Injectable } from "@nestjs/common";

@Injectable()
class TodoService {
	findForUser(id: string, userId: string) {
		return { id, title: "Write docs", userId };
	}

	createForUser(title: string, userId: string) {
		return { id: "todo-1", title, userId };
	}
}

@Controller()
class TodoController {
	constructor(private readonly todos: TodoService) {}

	@Router(api.todos)
	todosRouter(@Headers("x-user-id") userId: string) {
		return router(api.todos, {
			get: ({ id }) => this.todos.findForUser(id, userId),
			create: ({ title }) => ({
				status: 201 as const,
				body: this.todos.createForUser(title, userId),
			}),
		});
	}
}

Framework Context

Nest route handlers receive the context returned by createContext, plus an adapter-supplied abort signal.

type NestHandlerContext<TContext> =
	TContext & {
		signal: AbortSignal;
	};

Augment DefaultNestContext to set the app context used by RouteRequest, RouteHandler, RouteHandlers, route(), and router().

import type { Request } from "express";
import { RestRpcModule } from "@rest-rpc/nest";

declare module "@rest-rpc/nest" {
	interface DefaultNestContext {
		request: Request;
		userId: string;
	}
}

RestRpcModule.forRoot({
	createContext: (context) => {
		const request = context.switchToHttp().getRequest<Request>();

		return {
			request,
			userId: request.header("x-user-id") ?? "",
		};
	},
});

Options

type RestRpcModuleOptions<TContext = DefaultNestContext> = {
	createContext?: (context: ExecutionContext) => TContext | Promise<TContext>;
	errorHandlers?: ServerErrorHandlers<TContext & { signal: AbortSignal }>;
};

Error Handlers

Request validation errors use onRequestValidationError. Response contract validation errors use onResponseValidationError and default to a generic 500 response. Other unhandled route errors use onUnhandledError, or are re-thrown when that hook is omitted or returns undefined.

RestRpcModule.forRoot({
	errorHandlers: {
		onRequestValidationError: ({ issues }) => ({
			status: 422,
			body: { code: "VALIDATION_ERROR", issues },
		}),
		onResponseValidationError: () => ({
			status: 500,
			body: { code: "INVALID_RESPONSE" },
		}),
		onUnhandledError: () => ({
			status: 500,
			body: { code: "INTERNAL_SERVER_ERROR" },
		}),
	},
});

Was this page helpful?