Edit this page on GitHub

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 DecoratorPlugin registration.
  • No ExecutionContext/CanActivate/NestInterceptor: Setu-TS has no NestJS-shaped guard or interceptor interfaces. Guards and interceptors are bare MiddlewareFunctions attached with @UseGuards / @UseInterceptors (or registered programmatically via app.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 experimentalDecorators option 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

DecoratorMethodPath
@Get(path?)GETOptional path
@Post(path?)POSTOptional path
@Put(path?)PUTOptional path
@Patch(path?)PATCHOptional path
@Delete(path?)DELETEOptional path
@Head(path?)HEADOptional path
@Options(path?)OPTIONSOptional 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:

  1. Register DecoratorPlugin — required; nothing reads the metadata without it
  2. 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:

DecoratorProgrammatic
@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