Skip to content

Decorators Guide

Comprehensive guide to using decorators in Veloce.

Veloce uses TypeScript decorators to provide a clean, declarative API for defining routes, validation, and dependencies. Decorators are special functions that can modify classes, methods, and parameters at design time.

To use decorators, enable them in your tsconfig.json:

{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

Marks a class as a controller and optionally sets a path prefix for all routes.

@Controller('/api/users')
class UserController {
@Get('/')
async list() {
// Route: GET /api/users/
return [];
}
@Get('/:id')
async get(@Param('id') id: string) {
// Route: GET /api/users/:id
return { id };
}
}

Without prefix:

@Controller()
class RootController {
@Get('/health')
async health() {
// Route: GET /health
return { status: 'ok' };
}
}

Marks a class as a WebSocket handler.

@WebSocket('/chat')
class ChatHandler {
@OnConnect()
handleConnect(client: WebSocketConnection) {
console.log('Client connected');
}
@OnMessage()
handleMessage(client: WebSocketConnection, message: any) {
client.broadcast(message);
}
}

Marks a class as a GraphQL resolver.

@Resolver()
class UserResolver {
@Query()
async users() {
return [];
}
@Mutation()
async createUser(@Arg('input', UserInput) input: any) {
return { id: 1, ...input };
}
}
@Controller('/users')
class UserController {
@Get()
async list() {
// GET /users
return [];
}
@Get('/:id')
async get(@Param('id') id: string) {
// GET /users/:id
return { id };
}
@Get('/search')
async search(@Query() query: any) {
// GET /users/search?q=...
return [];
}
}
@Post()
async create(@Body(UserSchema) user: User) {
// POST /users
return user;
}
@Post('/bulk')
async createMany(@Body(z.array(UserSchema)) users: User[]) {
// POST /users/bulk
return users;
}
@Put('/:id')
async update(
@Param('id') id: string,
@Body(UserSchema) user: User
) {
// PUT /users/:id
return { id, ...user };
}
@Delete('/:id')
async delete(@Param('id') id: string) {
// DELETE /users/:id
return { success: true };
}
@Patch('/:id')
async patch(
@Param('id') id: string,
@Body(PartialUserSchema) data: Partial<User>
) {
// PATCH /users/:id
return { id, ...data };
}

Responds to all HTTP methods.

@All('/webhook')
async webhook() {
// Handles GET, POST, PUT, DELETE, etc.
return { received: true };
}
@Resolver()
class UserResolver {
@Query()
async users() {
return await db.getUsers();
}
@Query()
async user(@Arg('id', z.number()) id: number) {
return await db.getUserById(id);
}
}
@Mutation()
async createUser(@Arg('input', CreateUserInput) input: any) {
return await db.createUser(input);
}
@Mutation()
async deleteUser(@Arg('id', z.number()) id: number) {
return await db.deleteUser(id);
}
@Subscription()
async userCreated() {
return pubsub.asyncIterator('USER_CREATED');
}
@OnConnect()
handleConnect(client: WebSocketConnection) {
console.log(`Client ${client.id} connected`);
client.send({ type: 'welcome', message: 'Hello!' });
}
const MessageSchema = z.object({
type: z.string(),
content: z.string(),
});
@OnMessage(MessageSchema)
handleMessage(
client: WebSocketConnection,
message: z.infer<typeof MessageSchema>
) {
console.log('Received:', message);
client.broadcast(message);
}
@OnDisconnect()
handleDisconnect(client: WebSocketConnection) {
console.log(`Client ${client.id} disconnected`);
}

@UseMiddleware(...middleware: Middleware[])

Section titled “@UseMiddleware(...middleware: Middleware[])”

Apply middleware to a controller or specific route.

const authMiddleware = async (c, next) => {
const token = c.req.header('authorization');
if (!token) {
throw new HTTPException(401, 'Unauthorized');
}
await next();
};
const loggingMiddleware = async (c, next) => {
console.log(`${c.req.method} ${c.req.url}`);
await next();
};
// Apply to entire controller
@Controller('/admin')
@UseMiddleware(authMiddleware, loggingMiddleware)
class AdminController {
@Get('/dashboard')
async dashboard() {
return { data: 'protected' };
}
}
// Apply to specific route
@Controller('/users')
class UserController {
@Get('/')
async list() {
return [];
}
@Post('/')
@UseMiddleware(authMiddleware)
async create(@Body(UserSchema) user: User) {
return user;
}
}

These decorators provide a concise shorthand for documenting routes in the OpenAPI spec, as an alternative to the verbose @ApiDoc({...}).

Sets a short, one-line description visible in the Swagger UI route list.

@Get('/products')
@Summary('List all products')
async listProducts() {
return products;
}

Sets a longer description shown in the operation detail panel.

@Get('/products')
@Summary('List all products')
@Description('Returns a paginated list of active products. Supports filtering by category and price range.')
async listProducts() {
return products;
}

Assigns a single OpenAPI tag to the route. Can be stacked multiple times.

@Get('/products')
@Tag('Products')
@Tag('Catalog')
async listProducts() {
return products;
}

Assigns multiple tags in a single decorator.

@Get('/products')
@Tags('Products', 'Catalog', 'Public')
async listProducts() {
return products;
}

Marks the route as deprecated. It will appear with a strikethrough in Swagger UI.

@Delete('/products/:id')
@Deprecated()
@Summary('Delete a product (use PATCH /products/:id/archive instead)')
async deleteProduct(@Param('id') id: string) {
return { success: true };
}

:::note Auto-tagging When you don’t add any @Tag or @Tags, the OpenAPI generator automatically derives tags from the first path segment. For example, /products/:id gets the tag "Products". :::


Overrides the HTTP status code of the response. Also used by the OpenAPI generator to document the success response code.

Decorator order matters. TypeScript executes method decorators from bottom to top. @HttpCode must be placed above the HTTP-method decorator (@Post, @Get, etc.) so it runs after the route has been defined and can correctly overlay the status code.

@Post('/products')
@HttpCode(201)
async createProduct(@Body(CreateProductSchema) data: any) {
return await db.create(data);
}
@Delete('/products/:id')
@HttpCode(204)
async deleteProduct(@Param('id') id: string) {
await db.delete(id);
}

@ResponseSchema(schema: ZodSchema, statusCode?: number)

Section titled “@ResponseSchema(schema: ZodSchema, statusCode?: number)”

Validates and sanitizes the handler’s response using a Zod schema. Also informs the OpenAPI spec of the response model.

const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
});
@Get('/products/:id')
@ResponseSchema(ProductSchema)
async getProduct(@Param('id') id: string) {
return await db.findById(id);
// Response will be validated and sanitized against ProductSchema
}
@Post('/products')
@HttpCode(201)
@ResponseSchema(ProductSchema, 201)
async createProduct(@Body(CreateProductSchema) data: any) {
return await db.create(data);
}

Aborts the request with a 408 Request Timeout if the handler exceeds the time limit. Automatically injects the timeout middleware and emits the X-Timeout-Ms response header.

@Get('/report')
@Timeout(5000)
async generateReport() {
return await heavyReport();
}
@Post('/sync')
@Timeout(30000, 'Sync operation timed out. Try again later.')
async syncData() {
return await externalSync();
}

Applies rate-limiting to an individual route. Standard X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers are sent automatically.

@Post('/auth/login')
@RateLimit({ windowMs: 15 * 60_000, max: 10 })
async login(@Body(LoginSchema) credentials: any) {
return await authService.login(credentials);
}
@Post('/contact')
@RateLimit({ windowMs: 60_000, max: 3, message: 'Too many messages. Wait 1 minute.' })
async sendMessage(@Body(MessageSchema) data: any) {
return await mailer.send(data);
}

Extract and validate request body.

const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
});
@Post('/users')
async createUser(@Body(UserSchema) user: z.infer<typeof UserSchema>) {
// user is validated and typed
return user;
}

Without schema:

@Post('/data')
async postData(@Body() data: any) {
// No validation, any data accepted
return data;
}

Extract and optionally validate query parameters (stable since v0.2.6).

// Extract all query parameters
@Get('/search')
async search(@Query() query: any) {
const { q, page = 1, limit = 10 } = query;
return { query: q, page, limit };
}
// Extract specific parameter
@Get('/users')
async getUsers(@Query('page') page: string) {
return { page: parseInt(page) || 1 };
}
// Validate with Zod schema
const SearchSchema = z.object({
q: z.string().min(1),
page: z.string().transform(Number).default('1'),
limit: z.string().transform(Number).default('10'),
});
@Get('/search')
async search(@Query(SearchSchema) query: z.infer<typeof SearchSchema>) {
return {
query: query.q,
page: query.page,
limit: query.limit,
};
}

Extract route parameters.

@Get('/users/:id')
async getUser(@Param('id') id: string) {
return { id };
}
@Get('/users/:userId/posts/:postId')
async getPost(
@Param('userId') userId: string,
@Param('postId') postId: string
) {
return { userId, postId };
}

Extract all params:

@Get('/users/:userId/posts/:postId')
async getPost(@Param() params: { userId: string; postId: string }) {
return params;
}

@Header(name?: string, schema?: ZodSchema)

Section titled “@Header(name?: string, schema?: ZodSchema)”

Extract and validate headers.

@Get('/protected')
async getProtected(@Header('authorization') auth: string) {
return { auth };
}
const HeaderSchema = z.object({
'x-api-key': z.string(),
'x-request-id': z.string().uuid().optional(),
});
@Get('/api')
async apiEndpoint(@Header(HeaderSchema) headers: z.infer<typeof HeaderSchema>) {
return headers;
}

@Cookie(name?: string, schema?: ZodSchema)

Section titled “@Cookie(name?: string, schema?: ZodSchema)”

Extract and validate cookies.

@Get('/profile')
async getProfile(@Cookie('session') sessionId: string) {
return { sessionId };
}
const CookieSchema = z.object({
session: z.string(),
preferences: z.string().optional(),
});
@Get('/dashboard')
async dashboard(@Cookie(CookieSchema) cookies: z.infer<typeof CookieSchema>) {
return cookies;
}

Injects the Hono context (c). Use it when you need c.redirect(), c.req.json(), headers, or the underlying Web API Request.

import type { Context } from 'hono';
@Get('/redirect-example')
redirect(@Ctx() c: Context) {
return c.redirect('https://example.com', 302);
}
@Post('/echo-raw')
async echo(@Ctx() c: Context) {
const body = await c.req.json();
return body;
}

For the standard Request object (Fetch API), use the context and read c.req.raw:

@Get('/ip')
ip(@Ctx() c: Context) {
const req = c.req.raw; // Web standard Request
return { url: req.url };
}

Injects Hono’s request wrapper (c.req in the router). It is exported from veloce-ts as @Req. If you need the plain Web Request, prefer @Ctx() and c.req.raw.

@Get('/path')
path(@Req() req: import('hono').HonoRequest) {
return { path: req.path };
}

@Depends(provider: Provider, scope?: Scope)

Section titled “@Depends(provider: Provider, scope?: Scope)”

Inject dependencies.

class DatabaseService {
async getUsers() {
return [];
}
}
class UserService {
constructor(private db: DatabaseService) {}
async findUser(id: string) {
return this.db.getUsers().find(u => u.id === id);
}
}
@Controller('/users')
class UserController {
@Get('/')
async list(@Depends(DatabaseService) db: DatabaseService) {
return await db.getUsers();
}
@Get('/:id')
async get(
@Param('id') id: string,
@Depends(UserService) userService: UserService
) {
return await userService.findUser(id);
}
}

With scope:

@Get('/users')
async getUsers(
@Depends(DatabaseService, 'singleton') db: DatabaseService
) {
return await db.getUsers();
}

GraphQL argument with validation.

@Resolver()
class UserResolver {
@Query()
async user(@Arg('id', z.number().int().positive()) id: number) {
return await db.getUserById(id);
}
@Mutation()
async createUser(
@Arg('input', CreateUserInput) input: z.infer<typeof CreateUserInput>
) {
return await db.createUser(input);
}
}
@Post('/users/:id/posts')
async createPost(
@Param('id') userId: string,
@Body(PostSchema) post: Post,
@Header('authorization') auth: string,
@Depends(DatabaseService) db: DatabaseService
) {
// All parameters are extracted and validated
return await db.createPost(userId, post);
}
@Controller('/api/admin')
@UseMiddleware(authMiddleware, loggingMiddleware)
class AdminController {
@Get('/users')
async getUsers(@Depends(UserService) userService: UserService) {
return await userService.getAllUsers();
}
@Post('/users')
@UseMiddleware(validationMiddleware)
async createUser(
@Body(UserSchema) user: User,
@Depends(UserService) userService: UserService
) {
return await userService.createUser(user);
}
@Delete('/users/:id')
async deleteUser(
@Param('id') id: string,
@Depends(UserService) userService: UserService
) {
return await userService.deleteUser(id);
}
}
// ✓ Good
const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
@Post('/users')
async createUser(@Body(UserSchema) user: z.infer<typeof UserSchema>) {
return user;
}
// ✗ Bad
@Post('/users')
async createUser(@Body() user: any) {
return user;
}
// ✓ Good
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
@Post('/users')
async createUser(@Body(UserSchema) user: User) {
// user is fully typed
}
// ✗ Bad
@Post('/users')
async createUser(@Body(UserSchema) user: any) {
// Lost type safety
}
// ✓ Good - One controller per resource
@Controller('/users')
class UserController {
// All user-related routes
}
@Controller('/posts')
class PostController {
// All post-related routes
}
// ✗ Bad - Mixed concerns
@Controller('/api')
class ApiController {
@Get('/users')
async getUsers() {}
@Get('/posts')
async getPosts() {}
}
// ✓ Good
class UserService {
async getUsers() {
return [];
}
}
@Controller('/users')
class UserController {
@Get('/')
async list(@Depends(UserService) userService: UserService) {
return await userService.getUsers();
}
}
// ✗ Bad
@Controller('/users')
class UserController {
@Get('/')
async list() {
// Direct database access in controller
return await db.query('SELECT * FROM users');
}
}
// ✓ Good - Apply auth to entire admin controller
@Controller('/admin')
@UseMiddleware(authMiddleware)
class AdminController {
// All routes protected
}
// ✓ Good - Apply to specific sensitive routes
@Controller('/users')
class UserController {
@Get('/')
async list() {
// Public
}
@Delete('/:id')
@UseMiddleware(authMiddleware)
async delete(@Param('id') id: string) {
// Protected
}
}
@Get('/users/:id')
async getUser(@Param('id') id: string) {
const user = await db.getUserById(id);
if (!user) {
throw new HTTPException(404, 'User not found');
}
return user;
}