Custom decorators
Give a set of stages one name of your own, and attach typed facts to handlers for guards and other stages to read.
You'll learn
- Combine guards, cooldowns and other decorators into one
- Attach a typed fact to a handler with createMetadata
- Read that fact from a guard, an interceptor or a filter
A bot soon repeats itself: the same guard with the same settings and the same cooldown on a dozen commands.
applyDecorators combines decorators into one of your own, so the combination is
written once and named for what it means.
For facts about a handler, such as the roles it requires, createMetadata makes a
typed decorator that stores a value any stage can read back.
When to use it
Use applyDecorators when two or more handlers share the same stages with the same settings. A decorator named
@Protected or @StaffOnly says what a handler is, where a stack of four decorators says how.
Use createMetadata when a stage needs something to know about the handler it runs for. When the value configures one
use of a stage instead, such as the channels one command is allowed in, pass it as that stage's { provide, params },
as Guards shows.
Example
This decorator applies a guard with its settings and a cooldown:
import { applyDecorators } from 'meocord/common'
import { Cooldown, UseGuard } from 'meocord/decorator'
import { ChannelGuard } from '@src/guards/channel.guard'
// A guard with its options and a cooldown, as one decorator
export const Protected = (channelId: string, seconds = 5) =>
applyDecorators(UseGuard({ provide: ChannelGuard, params: { channelIds: [channelId] } }), Cooldown({ seconds }))A handler takes it like any decorator:
@Command('shop', CommandType.SLASH)
@Protected('111111111111111111', 10)
async shop(interaction: ChatInputCommandInteraction) {
await respond(interaction).send({ content: 'The shop is open.' })
}/shop now runs only in that channel, at most once every ten seconds per user.
How it works
applyDecorators returns a decorator that applies each one it was given to the class or the method it decorates, as
they would apply written one above the other: applyDecorators(A, B) does what @A @B does, so guards run in the
order listed. It works on a controller as well as on a handler, as long as every decorator in it does.
The handler ends up with exactly what the combined decorators give it, and
inspectHandler shows it:
describe('ShopSlashController', () => {
it('gets the guard and the cooldown the decorator combines', () => {
const shop = inspectHandler(ShopSlashController, 'shop')
expect(shop.guards).toEqual([{ provide: ChannelGuard, params: { channelIds: ['111111111111111111'] } }])
expect(shop.cooldowns).toEqual([expect.objectContaining({ seconds: 10 })])
})
})A decorator of your own that passes options on types them with the wrapped decorator's options type: DeferOptions
from meocord/decorator for @Defer, and from meocord/interface, CooldownOptions for @Cooldown, and
GuardOptions, InterceptorOptions, ObserverOptions and ValidateOptions for the stage classes and @Validate.
Facts about a handler
createMetadata<T>() returns a decorator that stores a value of type T on a handler or a controller. A stage reads it
with ExecutionContext.get(decorator), and a handler's value wins over its controller's. Each one has a unique key, so
two never collide:
import { type ChatInputCommandInteraction } from 'discord.js'
import { applyDecorators, createMetadata, ExecutionContext } from 'meocord/common'
import { Guard, UseGuard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
// A typed decorator that stores a value on a handler, or on a whole controller: here, the IDs of the roles it requires
export const Roles = createMetadata<string[]>('roles')
@Guard()
export class RolesGuard implements GuardInterface {
// Each call gets its own context, describing the handler being guarded
constructor(private readonly context: ExecutionContext) {}
canActivate(interaction: ChatInputCommandInteraction): boolean {
const required = this.context.get(Roles) ?? []
if (required.length === 0) return true
// A member's roles are keyed by ID, which is what Roles holds
return interaction.inCachedGuild() && required.some(roleId => interaction.member.roles.cache.has(roleId))
}
}
export const RequireRoles = (...roleIds: string[]) => applyDecorators(Roles(roleIds), UseGuard(RolesGuard))RequireRoles combines the metadata and the guard that reads it, so a handler takes one decorator for both.
ExecutionContext.getAll(decorator) reads every value declared, the method's first, then the controller's.
String keys
SetMetadata(key, value) stores a value under a key of your choosing, read with
ExecutionContext.get(key). Both are deprecated, and removed in the next major version (5.0): each logs a warning
once. Use createMetadata, whose values are typed and whose key can't collide with another library's. SetMetadata
throws as it applies for a key beginning meocord:, where MeoCord keeps its own metadata, and for the two keys
dependency injection reads; see the
upgrade guide.
Gotchas
- A bot written for 4.0 relied on the reverse order. 4.0's
applyDecorators(UseGuard(A), UseGuard(B))ranBbeforeA; 4.1 runsAfirst, as stacking them does. To keep the old order, list them the other way round; see the upgrade guide. - A method-only decorator can't go on a controller.
@Defer,@Validateand@UsePipego on handlers only, and refuse a class as they apply, naming the decorator, so a custom decorator that includes them does too.
Next steps
- Guards: read a handler's metadata from a guard.
- Testing: check what a handler ends up with through
inspectHandler. - Structuring your app: share the services your stages inject.