Skip to content
GitHub

Custom decorators

MeoCord 4.2 · The request pipeline · page 31 of 41 · since 4.1.0

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

Before this

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:

decorators/protected.decorator.ts
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:

controllers/slash/shop.slash.controller.ts
@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:

controllers/slash/shop.slash.controller.spec.ts
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:

guards/roles.guard.ts
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)) ran B before A; 4.1 runs A first, 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, @Validate and @UsePipe go 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.