Custom Decorators
Decorators are a fundamental feature of the Karin framework (and TypeScript ecosystem) that allow for declarative programming. They enable developers to annotate classes and methods with metadata, which the framework facilitates to change the behavior of the application at runtime.
In highly modular and complex applications, repetitive logic—such as extracting the authenticated user from a request, validating headers, or checking permissions—can clutter your business logic. Custom decorators allow you to abstract this logic into reusable, declarative annotations.
Concept: Uniforms and Badges
Think of decorators as Uniforms or Badges worn by the staff.
@Controller('users'): "I work in the Users section."@Get(): "I take orders."@Roles('admin'): "I only serve managers."- Custom Decorators allow you to stitch a new badge (e.g.,
@User()) that instantly identifies a specific piece of information without asking for ID every time.
Technical Overview: Parameter Decorators
While Karin provides a suite of built-in parameter decorators (like @Body, @Query, @Param), real-world applications often require extracting specific data associated with the custom logic of your domain.
For instance, consider authentication. In a traditional Express or Node.js application, you might see code that looks like this:
const user = req.user; // Implicitly attached by middleware
const tenantId = req.headers['x-tenant-id'];This approach has several downsides:
- Tight Coupling: It couples your controller method to the specific shape of the
Requestobject. - Type Safety:
req.useris often typed asany, leading to potential runtime errors. - Testability: To test this method, you must mock the entire Request object structure.
Karin solves this by allowing you to create custom decorators. A custom parameter decorator abstracts the extraction logic. Use createParamDecorator to define a factory that retrieves the data. Internally, Karin executes this factory when the route handler is invoked, extracts the return value, and injects it into the controller method's argument.
Creating a Decorator
The createParamDecorator function takes a callback. This callback receives two arguments: data (an optional value passed to the decorator) and ctx (the execution context).
Scenario: Extracting the Authenticated User
Let's assume you have an Authentication Guard running before your controller. This guard verifies a JWT and attaches the decoded user payload to the request object. We want a clean way to access this user.
import { createParamDecorator, ExecutionContext } from "@project-karin/core";
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
// 1. Access the Request Object
// The ExecutionContext abstracts the underlying platform. We switch to HTTP
// to access the standard request/response objects.
const request = ctx.switchToHttp().getRequest();
// 2. Extract Data
// We assume 'user' was attached by a Guard or Middleware previously.
const user = request.user;
// 3. Conditional Extraction
// If the decorator was called with an argument (e.g., @CurrentUser('email')),
// 'data' will be 'email'. We return just that property.
if (data) {
return user?.[data];
}
// 4. Return Full Object
// If no argument was provided, we return the entire user object.
return user;
},
);Usage in Controllers
Now, the controller method signature becomes cleaner and self-documenting. The framework handles the wiring.
@Get()
getProfile(@CurrentUser() user: UserEntity) {
// 'user' is injected automatically.
return user;
}
@Get('email')
getEmail(@CurrentUser('email') email: string) {
// 'email' is injected automatically.
return email;
}Advanced Usage: Execution Context
The ExecutionContext is a versatile wrapper. In standard HTTP applications, it wraps the native request/response objects (handled by Hono or H3 adapters). However, because Karin is designed to be platform-agnostic, simply accessing req.headers might not be enough if you move to a WebSocket or RPC context.
When writing generic decorators, always check the context type if your logic could be shared across different transport layers.
Scenario: Client IP Address Extraction
Extracting a client's IP address is a surprisingly complex task in modern infrastructure. The request might pass through a Load Balancer (AWS ALB), a CDN (Cloudflare), or a Reverse Proxy (Nginx). Each of these layers appends the original IP to a different header (usually X-Forwarded-For).
By encapsulating this logic in a decorator, you ensure that every part of your application resolves the IP address consistently.
export const ClientIp = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
// Logic to detect proxy headers
const forwarded = req.headers.get('x-forwarded-for');
const cloudflareIp = req.headers.get('cf-connecting-ip');
// Fallback to direct connection IP
const directIp = req.socket?.remoteAddress; // Platform dependent
return cloudflareIp || forwarded?.split(',')[0] || directIp;
},
);Decorator Composition
As your application scales, you will encounter the "Decorator Hell" problem. Secure, documented, and validated routes often require meaningful stacks of metadata.
The Problem:
@Get('admin')
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
@ApiResponse({ status: 403, description: 'Forbidden' })
getAdminBoard() { ... }This violates the DRY (Don't Repeat Yourself) principle. If you decide to change your AuthGuard or add a new global policy, you would have to refactor every single route.
The Solution: Karin provides the applyDecorators function. This allows you to bundle multiple decorators into a single, semantic, and reusable function.
Implementing Composite Decorators
Let's create an @AdminAuth() decorator that encapsulates all the security and documentation requirements for an administrative route.
import { applyDecorators, UseGuards, SetMetadata } from "@project-karin/core";
export function AdminAuth() {
return applyDecorators(
// 1. Authorization Logic
SetMetadata('roles', ['admin']),
UseGuards(AuthGuard, RolesGuard),
// 2. Documentation Logic
// (Assuming Swagger/OpenAPI integration)
// ApiBearerAuth(),
// ApiOperation({ summary: 'Admin only route' }),
);
}Usage
The controller code is now semantically clear. You read what the requirements are (@AdminAuth), not how they are implemented.
@AdminAuth()
@Get('dashboard')
getDashboard() {
return "Top Secret Data";
}This pattern is highly recommended for large teams. It centralizes configuration, ensuring that if security policies change (e.g., adding a new scope or claim requirement), the change is made in one place and propagates immediately to the entire codebase.
