Decorators Guide
The Setu-TS framework provides optional decorator support for a NestJS-like development experience. Decorators are optional - all capabilities can be accessed programmatically.
Overview
Decorators in Setu-TS are metadata-only. They do not automatically register routes or services.
The DecoratorPlugin reads the metadata and translates it into framework capabilities.
Key Differences from NestJS
- No reflection metadata: Setu-TS does not use
reflect-metadata. You must provide explicit injection tokens. - No emitted design metadata: Constructor parameter types are not automatically available. Use
@Inject(Token)for disambiguation. - Plugin-required: Decorators are inert without
DecoratorPluginregistration. - No
ExecutionContext/CanActivate/NestInterceptor: Setu-TS has no NestJS-shaped guard or interceptor interfaces. Guards and interceptors are bareMiddlewareFunctions attached with@UseGuards/@UseInterceptors(or registered programmatically viaapp.middleware.add). Prose below explains the NestJS differences; the copyable Setu-TS blocks never import those NestJS names.
Setup
Install Dependencies
deno add jsr:@setu-ts/decorator-plugin jsr:@setu-ts/di-plugin
Enable Decorators
Nothing to enable. Setu-TS uses TC39 standard decorators, which Deno, Bun and every current
bundler parse with no compilerOptions entry at all — so a project’s deno.json needs no decorator
setting, and adding one is actively unhelpful, because declaring any compiler option replaces Deno’s
default set.
The one exception is Node, where V8 has not shipped decorators: run the project through a transform
(setu new --runtime node emits tsx, which handles them) rather than through node’s built-in
type stripping. See Runtime & Deployment.
The legacy
experimentalDecoratorsoption is not used and must not be set. It is deprecated in Deno, and the surface it enabled — parameter decorators — has no place in the standard proposal at all.
Register Plugins
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DiPlugin } from '@setu-ts/di-plugin';
import { DecoratorPlugin } from '@setu-ts/decorator-plugin';
const app = createApplication();
app.register(RuntimePlugin());
app.register(DiPlugin()); // Optional: adds a container, so `scope` is honored
app.register(DecoratorPlugin()); // Required for decorator processing
DecoratorPlugin is required — decorators are inert without it. DiPlugin is not.
DecoratorPlugin branches on the container’s presence: with DiPlugin registered, an @Injectable
class is constructed through the container and its scope is honored; without it, the class is
constructed once and registered in the kernel’s ServiceRegistry. The decorated source is identical
either way — what changes is the lifecycle.
setu new app --template class-based scaffolds both together, which is the only combination the CLI
writes: the default templates install neither plugin, and the independent --di flag has been
removed. An older project may hold DecoratorPlugin alone, and it keeps working — that is the
container-less path described above. See the
CLI Guide.
Controllers
Basic Controller
import {
Body,
Controller,
Get,
Inject,
Injectable,
Param,
Params,
Post,
Query,
} from '@setu-ts/decorator-plugin';
@Injectable({ token: 'user-service' })
export class UserService {
findAll() {
return [{ id: '1', name: 'Alice' }];
}
findById(id: string) {
return { id, name: `user-${id}` };
}
create(dto: { name: string }) {
return { id: '2', ...dto };
}
}
@Controller('/users')
@Inject('user-service')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
findAll() {
return this.userService.findAll();
}
@Get('/:id')
@Params(Param('id'))
findOne(id: string) {
return this.userService.findById(id);
}
@Post()
@Params(Body())
create(dto: { name: string }) {
return this.userService.create(dto);
}
}
Register the controller and service with the DecoratorPlugin so the metadata is translated into
routes and DI registrations:
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DiPlugin } from '@setu-ts/di-plugin';
import {
Body,
Controller,
DecoratorPlugin,
Get,
Inject,
Injectable,
Param,
Params,
Post,
} from '@setu-ts/decorator-plugin';
@Injectable({ token: 'user-service' })
class UserService {
findById(id: string) {
return { id, name: `user-${id}` };
}
}
@Controller('/users')
@Inject('user-service')
class UserController {
constructor(private readonly userService: UserService) {}
@Get('/:id')
@Params(Param('id'))
findOne(id: string) {
return this.userService.findById(id);
}
@Post()
@Params(Body())
create(dto: { name: string }) {
return { id: '2', ...dto };
}
}
const app = createApplication({
plugins: [
RuntimePlugin(),
DiPlugin(),
DecoratorPlugin({
controllers: [UserController],
services: [UserService],
}),
],
});
await app.start({ port: 3000 });
HTTP Method Decorators
| Decorator | Method | Path |
|---|---|---|
@Get(path?) | GET | Optional path |
@Post(path?) | POST | Optional path |
@Put(path?) | PUT | Optional path |
@Patch(path?) | PATCH | Optional path |
@Delete(path?) | DELETE | Optional path |
@Head(path?) | HEAD | Optional path |
@Options(path?) | OPTIONS | Optional path |
Each method decorator accepts an optional path relative to the controller’s base path and defaults
to ''. Multiple HTTP decorators on the same method register one route per verb.
Controller Options
@Controller(path) takes a base path prefix string. Combine it with @Version('v1') to add an API
version prefix; the effective path is version + basePath + routePath (e.g. /v1/users).
import { Controller, Get, Version } from '@setu-ts/decorator-plugin';
@Controller('/api/users')
@Version('v1')
export class UserController {
@Get()
list() {
return [];
}
}
Dependency Injection
Injectable Classes
@Injectable and @Inject are the decorator-plugin’s DI decorators (the @setu-ts/di-plugin
package ships the container, not decorators). @Injectable marks a class for registration with an
optional scope and token; @Inject declares a constructor-parameter token.
import { Inject, Injectable } from '@setu-ts/decorator-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
interface UserRepository {
findAll(table: string): Promise<readonly { id: string; name: string }[]>;
}
@Injectable({ token: 'user-service' })
@Inject('user-repository', CAPABILITIES.CACHE)
export class UserService {
constructor(private readonly repo: UserRepository, private readonly cache: ICacheStore) {}
async findAll() {
// Check cache first
const cached = await this.cache.get<readonly { id: string; name: string }[]>('users:all');
if (cached !== null) return cached;
const users = await this.repo.findAll('users');
await this.cache.set('users:all', users, 60);
return users;
}
}
Where the instance lives depends on whether a container is present. DecoratorPlugin registers
a provider on the container when DiPlugin is registered, and it never touches the kernel registry
in that case — so ctx.services.get('user-service') resolves an @Injectable class only in a
project without DiPlugin. With a container, reach it by injecting it
(@Inject('user-service')), which is the path that works under both compositions.
Constructor Injection
@Inject(...) sits on the class and takes one token per constructor argument, in argument order
— the Nth entry binds the Nth parameter. There is no parameter-level form: the TC39 proposal has no
parameter position, so a decorator cannot be attached to an argument at all.
A token is always explicit, because type-inferred injection needs emitDecoratorMetadata, which
Deno does not support — so a parameter’s type cannot be read.
import { Inject, Injectable } from '@setu-ts/decorator-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
@Injectable()
@Inject(CAPABILITIES.CACHE)
export class UserRepository {
constructor(private readonly cache: ICacheStore) {}
}
Because the list is positional, reordering the constructor without reordering the tokens misinjects every argument — the list and the signature are one declaration and move together.
Optional Dependencies
Optional(token) wraps an entry inside the @Inject(...) list, in the position of the argument
it describes: when the token has no provider, that argument receives undefined instead of failing
construction. A token is still required.
import { Inject, Injectable, Optional } from '@setu-ts/decorator-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
@Injectable()
@Inject(Optional(CAPABILITIES.CACHE))
export class MyService {
constructor(private readonly cache?: ICacheStore) {}
}
Scoped Injection
@Injectable accepts a scope option using the ServiceScope string literal union
('singleton' | 'scoped' | 'transient') from @setu-ts/common. There is no Scope enum.
import { Injectable } from '@setu-ts/decorator-plugin';
@Injectable({ scope: 'scoped' })
export class RequestScopedService {}
Scopes:
'singleton'(default): Single instance per container'scoped': New instance per request scope'transient': New instance every injection
Request Data Access
Body
import { Body, Controller, Params, Post, ValidateBody } from '@setu-ts/decorator-plugin';
interface CreateUserDto {
name: string;
}
@Controller('/users')
export class UserController {
@Post()
@Params(Body())
async create(dto: CreateUserDto) {
// dto is the parsed JSON body; validated when a schema is attached
// with @ValidateBody and the ValidationPlugin is registered.
return dto;
}
}
Query Parameters
import { Controller, Get, Params, Query } from '@setu-ts/decorator-plugin';
@Controller('/users')
export class UserController {
@Get()
@Params(Query('page'), Query('limit'))
async findAll(page: string = '1', limit: string = '10') {
return { page: parseInt(page), limit: parseInt(limit) };
}
}
Path Parameters
import { Controller, Get, Param, Params } from '@setu-ts/decorator-plugin';
@Controller('/users')
export class UserController {
@Get('/:id')
@Params(Param('id'))
async findOne(id: string) {
return { id };
}
}
Headers
import { Controller, Get, Header, Params } from '@setu-ts/decorator-plugin';
@Controller('/users')
export class UserController {
@Get()
@Params(Header('Authorization'))
async list(auth: string) {
// auth contains "Bearer <token>" or "Basic <credentials>"
return { hasAuth: auth !== null };
}
}
Cookies
import { Controller, Cookie, Get, Params } from '@setu-ts/decorator-plugin';
@Controller('/users')
export class UserController {
@Get()
@Params(Cookie('session'))
async list(session: string) {
return { session };
}
}
The Authenticated Principal
CurrentUser() binds ctx.request.user (populated by authentication middleware). To read the full
request context, declare Ctx() — it resolves the live IRequestContext, so a handler can set a
status code, add a header, or stream. For anything else, register a resolver and bind it with
Custom() (see Custom Decorators).
import { Controller, CurrentUser, Get, Params } from '@setu-ts/decorator-plugin';
import type { IPrincipal } from '@setu-ts/common';
@Controller('/me')
export class MeController {
@Get()
@Params(CurrentUser())
async info(user: IPrincipal) {
return { user };
}
}
Validation
@ValidateBody, @ValidateQuery, and @ValidateParams attach a schema to a route. The schema is
stored on the route metadata and enforced only when the ValidationPlugin (or another schema-aware
middleware) is registered; without it the schema is inert.
import {
Body,
Controller,
Get,
Params,
Post,
Query,
ValidateBody,
ValidateQuery,
} from '@setu-ts/decorator-plugin';
// A validation schema is a plain object (Zod schema by convention); it is a
// VALUE, not a type, so @ValidateBody can attach it to the route metadata.
const createUserSchema = {
name: { type: 'string' as const, required: true },
email: { type: 'string' as const, required: true },
};
interface CreateUserDto {
name: string;
email: string;
}
@Controller('/users')
export class UserController {
@Post()
@ValidateBody(createUserSchema)
@Params(Body())
async create(dto: CreateUserDto) {
// dto is already validated
return dto;
}
@Get()
@ValidateQuery({
page: { type: 'number', optional: true, default: 1 },
limit: { type: 'number', optional: true, default: 10 },
})
@Params(Query())
async list(query: Record<string, unknown>) {
// query is validated
return query;
}
}
For programmatic (non-decorator) validation, the @setu-ts/validation-plugin package exports
validateBody and validateQuery middleware helpers used directly in route middleware arrays.
Guards
Setu-TS has no CanActivate interface or ExecutionContext. Guards are bare MiddlewareFunctions
(or IMiddleware classes) attached with @UseGuards, which may short-circuit by responding without
calling next().
import { Controller, Get, UseGuards } from '@setu-ts/decorator-plugin';
import type { IRequestContext, MiddlewareFunction } from '@setu-ts/common';
const authGuard: MiddlewareFunction = async (ctx, next) => {
const authHeader = ctx.request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return ctx.response.status(401).json({ error: 'Unauthorized' });
}
await next();
};
@Controller('/users')
export class UserController {
@Get('/protected')
@UseGuards(authGuard)
async protected() {
return { message: 'This is protected' };
}
}
Authorization Metadata
@Roles, @Permissions, and @Public attach authorization metadata to a route. The metadata is
stored but not enforced by this plugin; enforcement is the responsibility of guard middleware
registered by the auth plugin (e.g. requireAuth, requireRole, requirePermission from
@setu-ts/auth-plugin). @Public takes precedence over @Roles/@Permissions on the same target.
import { Controller, Get, Permissions, Public, Roles } from '@setu-ts/decorator-plugin';
@Controller('/admin')
export class AdminController {
@Get('/public')
@Public()
async publicInfo() {
return { info: 'public' };
}
@Get('/users')
@Roles('admin')
async listUsers() {
return [];
}
@Get('/reports')
@Permissions('reports:read')
async reports() {
return [];
}
}
Interceptors
Setu-TS has no NestInterceptor interface. Interceptors are bare MiddlewareFunctions (or
IMiddleware classes) attached with @UseInterceptors that wrap the handler invocation via
next().
import { Controller, Get, UseInterceptors } from '@setu-ts/decorator-plugin';
import type { IRequestContext, MiddlewareFunction } from '@setu-ts/common';
const transformInterceptor: MiddlewareFunction = async (ctx, next) => {
await next();
const snapshot = ctx.response.snapshot();
if (!snapshot.streaming && snapshot.body !== null) {
const data = typeof snapshot.body === 'string' ? JSON.parse(snapshot.body) : snapshot.body;
return ctx.response.json({ success: true, data });
}
};
@Controller('/users')
export class UserController {
@Get()
@UseInterceptors(transformInterceptor)
async list() {
return [{ id: '1', name: 'Alice' }];
}
}
Error Handling
Setu-TS does not ship a NestJS-shaped ExceptionFilter/ArgumentsHost contract. Errors are handled
by a single global error-handler middleware from @setu-ts/exceptions, registered as the outermost
middleware so it wraps the whole pipeline. The @UseFilters(...) decorator attaches per-route
filter middleware (bare MiddlewareFunctions or IMiddleware classes) that run last in the route’s
middleware chain, but the canonical, source-valid surface for global error handling is
errorHandler():
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { errorHandler } from '@setu-ts/exceptions';
const app = createApplication();
app.register(RuntimePlugin());
// Register the error handler as the outermost middleware (lowest priority
// number) so it catches errors thrown by any downstream middleware or route
// handler, formats them, and sends the response.
app.middleware.add(errorHandler({ format: 'rfc9457', logErrors: true }), {
priority: 0,
name: 'error-handler',
});
For per-route error handling, attach a filter middleware with @UseFilters:
import { Controller, Get, UseFilters } from '@setu-ts/decorator-plugin';
import type { IRequestContext, MiddlewareFunction } from '@setu-ts/common';
const routeErrorHandler: MiddlewareFunction = async (ctx, next) => {
try {
await next();
} catch (_error) {
return ctx.response.status(500).json({ error: 'route failure' });
}
};
@Controller('/risky')
export class RiskyController {
@Get()
@UseFilters(routeErrorHandler)
async risky() {
// Errors thrown here are caught by routeErrorHandler.
return { ok: true };
}
}
Custom Decorators
Parameter Source
Custom(name, metadata?) declares a parameter resolved at request time by a resolver registered
under the same name via registerParameterResolver. Declare it inside @Params(...) like any
built-in source. There is no ExecutionContext/switchToHttp surface — the resolver receives the
IRequestContext directly:
import {
Controller,
Custom,
Get,
Params,
registerParameterResolver,
} from '@setu-ts/decorator-plugin';
import type { IRequestContext } from '@setu-ts/common';
// Register the resolver that reads the tenant id from the request context.
// The registered name must match the source's name.
registerParameterResolver('tenant-id', (ctx: IRequestContext) => ctx.request.tenant?.id);
// A named helper keeps call sites readable and gives the value a type.
export const TenantId = () => Custom<string | undefined>('tenant-id');
@Controller('/items')
export class ItemController {
@Get()
@Params(TenantId())
async list(tenantId: string | undefined) {
return { tenantId };
}
}
Method/Class Decorator
createDecorator(name, metadata) stores class/method metadata replayed at registration time against
handlers registered under CAPABILITIES.DECORATOR_HANDLER.
import { Controller, createDecorator, Get } from '@setu-ts/decorator-plugin';
interface LoggingOptions {
level: 'info' | 'debug';
}
export const Log = (options?: LoggingOptions) =>
createDecorator('app:log', { level: options?.level ?? 'info' });
@Controller('/items')
export class ItemController {
@Log({ level: 'debug' })
@Get()
async list() {
return [];
}
}
Metadata Store
The MetadataStore is a plain Map-backed store keyed by class reference. Decorators write to the
shared metadataStore singleton at class-definition time; the DecoratorPlugin registers that
instance under CAPABILITIES.METADATA_STORE so ctx.metadata resolves to it. It exposes readonly
controllers, services, and routes maps plus lookup and mutation methods — there is no
set/get key-value API.
import { MetadataStore } from '@setu-ts/decorator-plugin';
const store = new MetadataStore();
// Inspect registered controllers and their materialized routes.
for (const [target, routes] of store.routes) {
console.log(target.name, routes.length, 'route(s)');
}
Discovery
Discover all controllers automatically with discoverControllers, which scans a directory and
imports files, attributing newly-appeared decorated classes to each file:
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { DiPlugin } from '@setu-ts/di-plugin';
import { DecoratorPlugin } from '@setu-ts/decorator-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
DiPlugin(),
DecoratorPlugin({
autoDiscover: true,
controllersPath: './src/controllers',
}),
],
});
await app.start({ port: 3000 });
Limitations
No reflect-metadata
Setu-TS does not emit design metadata. You must provide explicit tokens:
import { Inject, Injectable } from '@setu-ts/decorator-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
// ❌ This won't work - type information is not available
@Injectable()
class UserRepository {
constructor(private readonly cache: ICacheStore) {}
}
// ✅ Provide explicit token
@Injectable()
@Inject(CAPABILITIES.CACHE)
class UserRepositoryOk {
constructor(private readonly cache: ICacheStore) {}
}
No Automatic Registration
Decorators only add metadata. You must:
- Register
DecoratorPlugin— required; nothing reads the metadata without it - Register controllers and services with the plugin, or use
autoDiscover: true
DiPlugin is not on that list: injection works without a container, which resolves from the
kernel’s ServiceRegistry instead. Register it when you want a scoped or transient lifecycle.
No Method Overloading
Each decorator registers one route. For multiple methods, use separate methods:
import { Controller, Get, Post } from '@setu-ts/decorator-plugin';
@Controller('/items')
export class ItemController {
@Get('/items')
async getItems() {
return [];
}
@Post('/items')
async createItem() {
return { created: true };
}
}
Programmatic Equivalent
Every decorator has a programmatic equivalent:
| Decorator | Programmatic |
|---|---|
@Controller('/path') | app.router.get('/path', handler) |
@Injectable() | ctx.services.register('token', instance) |
@Inject('token') | N/A (injection configuration) |
@UseGuards(Guard) | Middleware: app.middleware.add(guardMiddleware) |
@UseInterceptors(Interceptor) | Middleware: app.middleware.add(interceptorMiddleware) |
@UseFilters(Filter) | Exception handler: app.middleware.add(errorHandler) |
Examples
Complete REST Controller
import {
Body,
Controller,
Delete,
Get,
Inject,
Injectable,
Param,
Params,
Post,
Put,
Query,
Roles,
UseGuards,
ValidateBody,
} from '@setu-ts/decorator-plugin';
import type { IRequestContext, MiddlewareFunction } from '@setu-ts/common';
const createUserSchema = {
name: { type: 'string' as const, required: true },
email: { type: 'string' as const, required: true },
};
interface CreateUserDto {
name: string;
email: string;
}
@Injectable({ token: 'user-service' })
export class UserService {
findAll(_opts: { page: number }) {
return [{ id: '1', name: 'Alice' }];
}
findById(id: string) {
return { id, name: `user-${id}` };
}
create(dto: CreateUserDto) {
return { id: '2', ...dto };
}
update(id: string, dto: CreateUserDto) {
return { id, ...dto };
}
async delete(_id: string) {
return { deleted: true };
}
}
const authGuard: MiddlewareFunction = async (ctx: IRequestContext, next) => {
const authHeader = ctx.request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return ctx.response.status(401).json({ error: 'Unauthorized' });
}
await next();
};
@Controller('/api/users')
@UseGuards(authGuard)
@Roles('admin')
@Inject('user-service')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@Params(Query('page'))
async findAll(page: string = '1') {
return this.userService.findAll({ page: parseInt(page) });
}
@Get('/:id')
@Params(Param('id'))
async findOne(id: string) {
return this.userService.findById(id);
}
@Post()
@ValidateBody(createUserSchema)
@Params(Body())
async create(dto: CreateUserDto) {
return this.userService.create(dto);
}
@Put('/:id')
@Params(Param('id'), Body())
async update(id: string, dto: CreateUserDto) {
return this.userService.update(id, dto);
}
@Delete('/:id')
@Params(Param('id'))
async delete(id: string) {
await this.userService.delete(id);
return { deleted: true };
}
}
Next Steps
- Programmatic API - Complete API reference without decorators
- Plugin Architecture - Understanding the underlying system