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
npm install @setu-ts/decorator-plugin @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 asserts a setting nothing in the project reads. Declaring a compiler option
does not disturb Deno’s other defaults (measured on Deno 2.9.6), so the rest of an existing
compilerOptions block can stay exactly as it is.
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
The container falls back to the kernel’s service registry for a token it has no provider for, so a
class can inject a framework capability such as CAPABILITIES.CACHE or CAPABILITIES.LOGGER, which
live in the registry rather than the container. DiPlugin({ autoRegister: false }) turns the
fallback off.
On 0.8.0 and earlier the default was false, so pass autoRegister: true there.
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 rest --style 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. --style is its own axis on the styleable templates (rest and
microservice), and --template class-based is a byte-identical alias of
--template rest --style class-based. An older project may hold DecoratorPlugin alone, and it
keeps working — that is the container-less path described above. See the
CLI Guide.
Non-HTTP ingress
The same explicit class list can register queue processors, scheduled jobs, domain-event and broker
subscriptions, WebSocket gateways, and CQRS handlers. Put those classes in ingress; registration
happens during onInit, after the corresponding provider plugin has registered its capability. A
declared handler with no provider fails startup with the class, method, and missing plugin named.
import type {
CqrsCommand,
CqrsQuery,
IDomainEvent,
IJob,
IWebSocketConnection,
} from '@setu-ts/common';
import {
CommandHandler,
Cron,
Every,
Gateway,
OnEvent,
OnMessage,
Processor,
QueryHandler,
Subscribe,
} from '@setu-ts/decorator-plugin';
class BackgroundWork {
@Processor('email')
process(_job: IJob): void {}
@Cron('0 2 * * *')
nightly(): void {}
@Every(60_000)
poll(): void {}
@OnEvent('user.created')
created(_event: IDomainEvent): void {}
@Subscribe('user.created')
replicated(_message: unknown): void {}
@CommandHandler('create-user')
command(_command: CqrsCommand): void {}
@QueryHandler('find-user')
query(_query: CqrsQuery): void {}
}
@Gateway('/ws/updates')
class UpdatesGateway {
@OnMessage
message(_connection: IWebSocketConnection, _data: string | Uint8Array): void {}
}
Register the provider plugins that a class uses (QueuePlugin, SchedulerPlugin, EventsPlugin,
MessagingPlugin, WebSocketPlugin, or CqrsPlugin) alongside the decorator plugin:
DecoratorPlugin({ ingress: [BackgroundWork, UpdatesGateway] }). @UseIngressBehaviors(...) wraps
queue, scheduler, messaging, and WebSocket handlers; @UsePipelineBehaviors(...) wraps only command
and query handlers. The decorators are deliberately separate because the two public behavior
contracts take different contexts. Domain events receive no ingress-behavior wrapper.
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, so the controller below
serves GET /v1/api/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 PerScopeService {}
Scopes:
'singleton'(default): one instance for the application lifetime'scoped': one instance per scope — each request gets its own scope fromcontainer.forRequest(ctx), created on first use and disposed when the request finishes'transient': a new instance per resolution
Lifetimes are checked at boot. The container validates its whole graph when the application
starts: a 'singleton' that reaches a 'scoped' service — directly or through 'transient'
services — fails the boot with ContainerValidationError, instead of silently keeping the first
scope’s instance for the life of the application. Resolving a 'scoped' service outside a scope
throws ScopeRequiredError.
A handler written without decorators reaches the request’s scope through the container:
import { CAPABILITIES, type IContainer } from '@setu-ts/common';
import { createApplication } from '@setu-ts/kernel';
const app = createApplication();
app.router.get('/reports', (ctx) => {
const scope = ctx.services.get<IContainer>(CAPABILITIES.DI_CONTAINER).forRequest(ctx);
const reports = scope.resolve<{ readonly rows: string[] }>('per-scope');
return ctx.response.json({ rows: reports.rows.length });
});
A decorated controller takes scoped services as handler parameters. registerController builds
a controller once, during route registration, before any request exists, so it is a singleton
and its constructor cannot take a 'scoped' service — the boot refuses that graph, and the error
names the fix. Declare the service as a handler parameter with Service(token), which resolves it
from the request’s scope on every call:
import { Controller, Get, Injectable, Params, Service } from '@setu-ts/decorator-plugin';
@Injectable({ scope: 'scoped', token: 'per-scope' })
export class ScopedReportService {
readonly rows: string[] = [];
}
@Controller('/reports')
export class ReportController {
// NOT `constructor(private readonly reports: ScopedReportService)` — a
// controller is a singleton, and boot validation refuses that dependency.
@Get()
@Params(Service<ScopedReportService>('per-scope'))
today(reports: ScopedReportService) {
return { rows: reports.rows.length };
}
}
apps/di-decorators serves a
/lifetimes route that demonstrates the difference between the three lifetimes across two requests.
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 | undefined) {
// "Bearer <token>" or "Basic <credentials>"; undefined when the header is absent.
return { hasAuth: auth !== undefined };
}
}
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 compute
a status code, add a header, or stream. (For a status or header that is FIXED for the route, prefer
the declarative form in Response Shaping.) 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 };
}
}
Response Shaping
A decorated handler that returns a plain value is answered with ctx.response.json(result). Left
undecorated that is the DEFAULT — 200, JSON, no headers — and the three method decorators below
change it, each writing to the response builder before the handler runs. So a handler can state
something fixed about its response in its declaration, without accepting a request context it has no
other use for.
| Decorator | What it sets |
|---|---|
@HttpCode(status) | The success status for a plain return |
@ResponseHeader(name, value) | One response header (repeatable, distinct names) |
@Redirect(url, status?) | The status and Location; 302 by default |
import {
Body,
Controller,
Get,
HttpCode,
Params,
Post,
Redirect,
ResponseHeader,
} from '@setu-ts/decorator-plugin';
@Controller('/orders')
export class OrderController {
@Post()
@HttpCode(201)
@Params(Body())
create(order: { sku: string }) {
return { id: 'o-1', sku: order.sku };
}
@Get('/latest')
@ResponseHeader('Cache-Control', 'no-store')
@ResponseHeader('X-Report-Version', '3')
latest() {
return { sku: 'a-1' };
}
@Get('/v1')
@Redirect('/orders', 301)
legacy() {
return { moved: true };
}
}
Which to reach for. Use these three when the value is fixed for the route. Use Ctx() when it
is computed per request — a status that depends on whether a record already existed, a header
carrying a per-request value, or a multi-valued header such as Set-Cookie, which needs
ctx.response.appendHeader(...). Ctx() is not deprecated by these and is not replaced by them.
Three rules are worth knowing before you use them.
A returned HandlerResult wins. The declared shaping is written to the response builder
before the handler method runs, so a handler that returns ctx.response.status(202).json(...)
answers 202 even under @HttpCode(201) — the explicit runtime value is the more specific
statement. The same ordering is why @Render composes: the rendered HTML is written onto a builder
that already carries the status.
@Redirect does not skip the handler. A decorator cannot decline to call the method, so the
handler still runs and a plain return is still serialised alongside the Location header. That is
the point of the declarative form: the handler’s work is the side effect. A handler that wants to
decide per request should call ctx.response.redirect(url) itself through Ctx(), which terminates
the response.
Every argument is checked at startup, never per request. @HttpCode takes an integer in
[200, 599]; @Redirect takes one in [300, 399] and a non-blank target the runtime will carry as
a Location header; a header name and value must be ones the runtime accepts; the same header name
may not be declared twice; and one handler may not carry both @HttpCode and @Redirect, because
both set the status. Each refusal names the controller, the method and the offending value. The
alternative is worse than a startup failure: an out-of-range status throws inside the HTTP adapter
after the middleware pipeline has finished, where no error handler can answer it, and an invalid
header pair throws while the response headers are written, so every request to that route would
answer 500.
@HttpCode(204) (and 205, and 304) serves a bodiless response — the runtime drops a body
written at a null-body status — which is exactly what a DELETE handler wants.
The document learns the status too. @setu-ts/openapi-plugin derives an operation’s success
status from what the handler declared, so a @HttpCode(201) route is documented under 201 and its
generated client types the success body under the right key. That derivation is on by default and is
described under deriveResponseStatus in PUBLIC_API.md. @ResponseHeader is
deliberately not derived: an OpenAPI response-header entry needs a schema and a description the
declaration does not carry.
Validation
@ValidateBody, @ValidateQuery, and @ValidateParams attach a Zod-compatible schema to a route.
With the ValidationPlugin registered, DecoratorPlugin enforces it: an invalid request is
answered 400 before the handler runs, and @Params(Body()) / @Params(Query()) then receive the
parsed value, with the schema’s defaults and coercions applied. Without a validation provider
the schema is not enforced, and register() logs a warning naming each such route.
DecoratorPlugin({ enforceSchemas: false }) turns enforcement off deliberately.
import {
Body,
Controller,
Get,
Params,
Post,
Query,
ValidateBody,
ValidateQuery,
} from '@setu-ts/decorator-plugin';
import { z } from 'zod';
// The schema must be a Zod-compatible VALUE. A plain description object such as
// `{ name: { type: 'string' } }` is not one, and every request to the route
// would answer 500.
const createUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
const listQuerySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
});
@Controller('/users')
export class UserController {
@Post()
@ValidateBody(createUserSchema)
@Params(Body())
async create(dto: z.infer<typeof createUserSchema>) {
// dto is already validated
return dto;
}
@Get()
@ValidateQuery(listQuerySchema)
@Params(Query<z.infer<typeof listQuerySchema>>())
async list(query: z.infer<typeof listQuerySchema>) {
// GET /users?page=3 → { page: 3, limit: 10 }: the parsed query, defaults
// and coerced numbers included.
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, and
DecoratorPlugin enforces @Roles and @Permissions. A restricted route answers 401 to an
unauthenticated request and 403 to a principal without the role or permission, before the handler
runs. It fails closed: with no authorization service registered (AuthPlugin with an rbac
option), it answers 501 rather than serving the route unguarded, and register() warns once per
affected route. Several roles on one route mean any of them; roles and permissions together mean
both. @Public marks a route public for the OpenAPI document.
DecoratorPlugin({ enforceRoles: false }) restores metadata-only behaviour.
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';
// The shared singleton the decorators write to. A `new MetadataStore()` is a
// separate, empty store.
for (const [target, routes] of metadataStore.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',
}),
],
});
await app.start({ port: 3000 });
Discovery scans recursively from that root (skipping dot-directories and node_modules), so ./src
also reaches the @Controller classes inside src/modules/<name>/ — pointing it at
./src/controllers alone would miss every module controller.
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
TypeScript has no runtime overloads, so one method is one handler. Two verb decorators on one method register the same handler for both verbs; when the verbs need different behaviour, use separate methods. Paths are relative to the controller’s base path:
import { Controller, Get, Post } from '@setu-ts/decorator-plugin';
@Controller('/items')
export class ItemController {
@Get() // GET /items
async getItems() {
return [];
}
@Post() // 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';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
type CreateUserDto = z.infer<typeof createUserSchema>;
@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();
};
// @Roles is enforced: register AuthPlugin with an `rbac` option, or every
// request to this controller answers 501.
@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