Custom

Build your own decorators with createClassDecorator, createMethodDecorator, createDecorator, createFieldDecorator, and the metadata helpers.

@miiajs/core exposes the same factory API that the framework uses internally. Four factories cover every decorator shape you need; five metadata helpers cover every storage shape. You never touch the raw TC39 decorator signatures yourself - the factories wrap them and forward your handler the arguments you declared.

When to reach for a custom decorator

  • Tag controllers or routes with domain-specific metadata (@Cache(60), @RateLimit(100), @Audit('user.delete')).
  • Register a method with an external system at load time (a scheduled job, an event handler, an RPC endpoint).
  • Expose a declarative knob that some middleware or guard reads at runtime.

The rule of thumb: decorators attach data, middleware does things. If the behaviour is a request/response transformation, write middleware instead.

The four factories

import {
  createClassDecorator,
  createMethodDecorator,
  createFieldDecorator,
  createDecorator,
} from '@miiajs/core'
FactoryApplies toBuilt-in example
createClassDecoratorClasses@Injectable, @Controller, @ApiTag
createMethodDecoratorMethods@Get, @Status, @ValidateBody, @ApiOperation
createFieldDecoratorClass fields(rarely used - see below)
createDecoratorClass or method@Use, @UseGuard, @SkipGuard, @ApiSecurity

Each factory takes a handler that receives the decorator context (plus any arguments you declared) and returns a ready-to-use decorator function.

Metadata helpers

Use a Symbol for the storage key - string keys would collide with user-land properties on Symbol.metadata:

export const CACHE = Symbol('cache')

Then pick the helper that matches the shape you need:

HelperShapeUse case
setMeta(meta, key, value)scalarOne value per class (e.g. route prefix)
getMeta<T>(ctor, key)scalar readRead back from the constructor
pushMeta(meta, key, item)arrayAppend-only list (e.g. all routes on a controller)
addToMapMeta(meta, key, name, items)Map<string, T[]>Per-method list (e.g. middlewares per handler)
setInMapMeta(meta, key, name, value)Map<string, T>Per-method single value (e.g. status code per handler)

All write helpers expect the context.metadata object inside a handler. getMeta takes the class itself and reads Symbol.metadata later.

Walkthrough: class decorator

A minimal example - tag a controller with a feature flag:

import { createClassDecorator, setMeta } from '@miiajs/core'

export const FEATURE = Symbol('feature')

export const Feature = createClassDecorator<[name: string]>((_target, context, name) => {
  setMeta(context.metadata!, FEATURE, name)
})

Usage:

@Feature('billing')
@Controller('/invoices')
class InvoiceController { /* ... */ }

The generic <[name: string]> declares the decorator's arguments. createClassDecorator forwards them to the handler after target and context.

Why context.metadata!? TC39 types context.metadata as optionally undefined because decorators can theoretically be applied to non-class targets. In MiiaJS it's always defined - the polyfill guarantees it - so the non-null assertion is idiomatic and safe inside a handler.

Walkthrough: method decorator

A @Cache(ttl) decorator that records a TTL per method:

import { createMethodDecorator, setInMapMeta } from '@miiajs/core'

export const CACHE = Symbol('cache')

export const Cache = createMethodDecorator<[ttlSeconds: number]>((_target, context, ttlSeconds) => {
  setInMapMeta(context.metadata!, CACHE, String(context.name), ttlSeconds)
})

Usage:

@Controller('/products')
class ProductController {
  @Get('/')
  @Cache(60)
  list() { /* ... */ }

  @Get('/:id')
  @Cache(300)
  findOne() { /* ... */ }
}

setInMapMeta stores one value per method name. Use addToMapMeta instead when a decorator can be applied multiple times and you need the accumulated list - that's how @Use and @UseGuard work internally.

A more complex example from the framework itself: @ValidateBody writes both a schema (via setInMapMeta) and a middleware (via addToMapMeta) in a single handler - see packages/core/src/decorators/middleware.ts for the source.

Walkthrough: dual class/method decorator

createDecorator handles decorators that can be stacked on either a class or an individual method. The handler receives a union context, so you branch on context.kind:

import { createDecorator, setMeta, setInMapMeta } from '@miiajs/core'

export const RATE_LIMIT = Symbol('rateLimit')

export const RateLimit = createDecorator<[perMinute: number]>((context, perMinute) => {
  if (context.kind === 'class') {
    setMeta(context.metadata!, RATE_LIMIT, perMinute)
  } else {
    setInMapMeta(context.metadata!, RATE_LIMIT, String(context.name), perMinute)
  }
})

Now both forms work, and a method-level value overrides the class-level default:

@Controller('/api')
@RateLimit(100)           // 100 rpm for the whole controller
class ApiController {
  @Get('/status')
  status() { /* ... */ }

  @Post('/expensive')
  @RateLimit(5)           // 5 rpm - overrides the controller default
  expensive() { /* ... */ }
}

This pattern mirrors how @UseGuard works internally - see packages/core/src/decorators/middleware.ts for the reference implementation.

Composing decorators with applyDecorators

Sometimes a route needs several decorators that always appear together - a guard, a validator, a status code, a cache tag. Copy-pasting the stack on every method is noisy and easy to get out of sync. applyDecorators collapses the stack into a single reusable decorator without you touching metadata helpers at all - you just hand it the existing decorators and get back a composed one.

import { applyDecorators, UseGuard, Status } from '@miiajs/core'
import { AuthGuard } from '@miiajs/auth'
import { Roles } from './roles.guard.js'
import { JwtAuth } from './providers/jwt-auth.provider.js'

export const AdminOnly = () =>
  applyDecorators(
    UseGuard(AuthGuard(JwtAuth)),
    UseGuard(Roles('admin')),
    Status(204),
  )

Usage is a single decorator at the call site:

@Controller('users')
class UsersController {
  @Delete(':id')
  @AdminOnly()
  remove(ctx: RequestContext) { /* ... */ }
}

How it works

Each decorator you pass into applyDecorators is a plain function (value, context) => ... - exactly what createMethodDecorator, createDecorator, and hand-written method decorators produce. applyDecorators returns a new method decorator that invokes every input in order, forwarding the same value and context. Because they share the context.metadata object, every side-effect (setMeta, pushMeta, addToMapMeta, setInMapMeta) lands on the same target - exactly as if you had stacked them by hand.

There is no runtime cost on requests: composition happens once, when the class is declared. At request time the framework sees the accumulated metadata, not applyDecorators itself.

When to use it

  • A common cross-cutting recipe you apply to many routes (@AdminOnly(), @PublicJson(), @IdempotentPost()).
  • Parameterised combinations (RequireRole(...roles), Cached(ttl, tags)).
  • Wrapping third-party decorators with your project's defaults (@ValidateBody(YourSchema) + @Status(201) as a single @CreateEndpoint(schema)).

When not to use it

  • A single decorator - just use it directly.
  • Decorators that write to different metadata shapes under the same key and expect a specific order. Stacked decorators always run bottom-up, and applyDecorators runs its arguments left-to-right; if you're relying on precise ordering, document it where the composite is defined.

Limitations

applyDecorators is method-level only. Composing class decorators has a different shape (different context.kind, different return semantics) and isn't covered by this helper yet. If you need that, stack the class decorators directly on the target class or write a tiny class-specific composer that mirrors the same pattern.

Reading metadata back

Attaching metadata is only half the job - something has to consume it. The consumer is usually a middleware, a guard, or an onInit() hook that walks every controller. It reads metadata with getMeta, passing the class constructor:

import { getMeta, type Middleware } from '@miiajs/core'
import { CACHE } from './cache.decorator.js'

export const cacheMiddleware =
  (controller: Function, handlerName: string): Middleware =>
  async (ctx, next) => {
    const map = getMeta<Map<string, number>>(controller as any, CACHE)
    const ttl = map?.get(handlerName)
    if (!ttl) return next()

    // Use the TTL to look up / store a cached response.
    // ...
    await next()
  }

Reading metadata is a built-in pattern across the framework, not a Swagger-only trick. Three canonical examples:

  • packages/core/src/app/router-explorer.ts - the router walks every controller during app.init() and pulls ROUTES, CLASS_MW, METHOD_MW, CLASS_GUARDS, METHOD_GUARDS, and STATUSES to assemble each route's pipeline.
  • packages/core/src/app/module-loader.ts - the module loader reads MODULE, INJECTABLE, and PREFIX from class metadata to wire up DI registration and route prefixes.
  • packages/swagger/src/builder/spec-builder.ts - the Swagger spec builder reads core metadata (ROUTES, CLASS_GUARDS, STATUSES, BODY_SCHEMAS, QUERY_SCHEMAS, PARAMS_SCHEMAS) plus its own custom keys (API_TAG, API_OPERATIONS, API_RESPONSES, API_SECURITY, API_HEADERS) to generate an OpenAPI 3.1 document.

The pattern is the same in all three: walk the controllers, call getMeta with the keys you care about, and act on the data.

Field decorators

Field decorators fire when a field initializer runs. createFieldDecorator passes only the context - there's no runtime "target" for a field at decoration time - so you typically register an initializer that runs against the constructed instance:

import { createFieldDecorator } from '@miiajs/core'

export const Tracked = createFieldDecorator<[label: string]>((context, label) => {
  context.addInitializer(function () {
    console.log(`[tracked] ${label}: ${String(context.name)} initialized on`, this)
  })
})

In practice, field decorators are rare in MiiaJS apps - inject() as a field initializer already covers the most common case (wiring a field through the DI container). Reach for createFieldDecorator only when you're building a sibling framework on top of @miiajs/core and need to express something that doesn't fit on a class or a method.

Exporting symbol keys from a library

If your decorator lives in a published package, export its symbol from the public entry point. That lets downstream consumers (middleware, guards, plugins, other libraries) read the metadata without re-declaring their own symbols:

// my-pkg/src/index.ts
export { Cache, CACHE } from './cache.decorator.js'

@miiajs/core itself does this - INJECTABLE, ROUTES, CLASS_GUARDS, BODY_SCHEMAS, and the rest are all public exports specifically so other packages (Swagger, Auth) can read core metadata without reaching into internals.

Gotchas

  • Metadata is per-class, not per-instance. Decorators run once when the class is declared. If you store a counter or any kind of mutable state, every instance of the class shares it.
  • The DI container is not active inside a decorator handler. Decorators run at class definition time, long before app.init(). If you need a resolved service, attach metadata now and read it inside an onInit() hook or a middleware where the container is active.
  • Stacked decorators run bottom-up, but they all share the same context.metadata object. Order only matters when one decorator reads what another just wrote - most decorators only write, so it's usually irrelevant. The exception is easy to miss and hard to debug, so keep it in mind if you build interacting decorators.
  • Always use Symbol keys. String keys would collide with user-land properties on Symbol.metadata.
  • The polyfill is already loaded. @miiajs/core imports ./polyfill.js as the first line of its entry - as long as your code (including tests) imports anything from @miiajs/core transitively, Symbol.metadata is ready.