Custom
@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'
| Factory | Applies to | Built-in example |
|---|---|---|
createClassDecorator | Classes | @Injectable, @Controller, @ApiTag |
createMethodDecorator | Methods | @Get, @Status, @ValidateBody, @ApiOperation |
createFieldDecorator | Class fields | (rarely used - see below) |
createDecorator | Class 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:
| Helper | Shape | Use case |
|---|---|---|
setMeta(meta, key, value) | scalar | One value per class (e.g. route prefix) |
getMeta<T>(ctor, key) | scalar read | Read back from the constructor |
pushMeta(meta, key, item) | array | Append-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 typescontext.metadataas 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
applyDecoratorsruns 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 duringapp.init()and pullsROUTES,CLASS_MW,METHOD_MW,CLASS_GUARDS,METHOD_GUARDS, andSTATUSESto assemble each route's pipeline.packages/core/src/app/module-loader.ts- the module loader readsMODULE,INJECTABLE, andPREFIXfrom 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 anonInit()hook or a middleware where the container is active. - Stacked decorators run bottom-up, but they all share the same
context.metadataobject. 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
Symbolkeys. String keys would collide with user-land properties onSymbol.metadata. - The polyfill is already loaded.
@miiajs/coreimports./polyfill.jsas the first line of its entry - as long as your code (including tests) imports anything from@miiajs/coretransitively,Symbol.metadatais ready.