Skip to content
GitHub

Validation and pipes

MeoCord 4.1 · The request pipeline · page 26 of 41 · since 4.1.0

Check a handler's input against a schema, and turn valid values into what it works with, before the handler runs.

You'll learn

  • Validate a handler's input with zod, valibot or any Standard Schema library
  • Turn a validated value into an object with a pipe
  • Type the handler's params from the schema and its pipes

@Validate checks a handler's input before it runs, so the handler receives typed, valid values or doesn't run at all. It takes a schema from any library that implements Standard Schema, such as zod, valibot or arktype, so MeoCord bundles no validator and you keep the one you know.

A pipe then turns one valid value into what the handler works with: an account ID into the account, say.

When to use it

Validate what Discord can't check for you: a number's range, a string's format, a customId param a user could have tampered with, a modal field's length. The user is told exactly what's wrong.

Discord already checks a slash command option's type and its setMinValue or setMaxLength, so a builder is the first place for those. A customId param's type, such as a number, can go in its pattern, {count:int}, as Components shows. Who may run the handler isn't input; that's a guard.

Example

controllers/slash/remind.slash.controller.ts
const Reminder = z.object({
  minutes: z.number().int().min(1).max(1440),
  note: z.string().max(200).default(''),
})

@Controller()
export class RemindSlashController {
  @Command('remind', CommandType.SLASH)
  @Validate(Reminder)
  // The second parameter is checked against the schema's output: `{ minutes: string }` would not compile
  async remind(interaction: ChatInputCommandInteraction, { minutes, note }: z.output<typeof Reminder>) {
    await respond(interaction).send({
      content: `In ${minutes} minutes: ${note || 'a reminder'}`,
      flags: MessageFlags.Ephemeral,
    })
  }
}

The schema's output is what the handler receives, so note defaults to '' when the option is left out. The second parameter is checked against that output when the code compiles. A /remind with minutes: 0 doesn't run the handler, and the user is told privately which value is wrong and why.

How it works

Validation runs after the guards, inside the interceptors, so an interceptor sees invalid input as the handler's error. Pipes run right after it, then cooldowns count the call. Input that fails never uses up a cooldown.

The input is one object, the handler's second argument:

HandlerIts input
Slash commandits options
Buttonits customId params
Select menuits customId params, values, and the chosen users, members, roles or channels
Modalits customId params and its fields
Message command with patternits pattern's params

Invalid input throws ValidationError, whose issues list each problem and where it is. The built-in fallback answers it: privately after an interaction, and after a message command as a reply in the channel that's deleted like a usage reply. Schema libraries write their messages in English; an exception filter that maps the issues to your own words is where to translate them.

controllers/slash/remind.slash.controller.spec.ts
it('receives the schema’s output, defaults applied', async () => {
  const interaction = remind({ minutes: 30 })

  await module.invoke(RemindSlashController, 'remind', interaction)

  expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'In 30 minutes: a reminder' })
})

it('never runs with input the schema refuses', async () => {
  const interaction = remind({ minutes: 0 })

  await expect(module.invoke(RemindSlashController, 'remind', interaction)).rejects.toBeInstanceOf(ValidationError)
  expect(getResponse(interaction).sent).toBe(false)
})

A handler takes one @Validate. To check several things, combine them in one schema.

Pipes

A pipe is a class marked @Pipe() that implements PipeInterface. Its transform takes one value and returns another:

pipes/account.pipe.ts
import { Pipe } from 'meocord/decorator'
import { type PipeInterface } from 'meocord/interface'
import { type Account, AccountService } from '@src/services/account.service'

// Resolved like a service, so it can inject one; one instance serves every call
@Pipe()
export class AccountPipe implements PipeInterface<string, Account> {
  constructor(private readonly accounts: AccountService) {}

  transform(uid: string): Account {
    return this.accounts.find(uid)
  }
}

Give pipes to @Validate for the schema's keys, and the handler's parameter is typed with what they produce:

controllers/button/account.button.controller.ts
// The pattern captures a uid; the schema checks it, and the pipe turns it into an account
@Command('account/{uid}', CommandType.BUTTON)
@Validate(z.object({ uid: z.string().regex(/^\d{9,10}$/) }), { pipes: { uid: AccountPipe } })
async show(interaction: ButtonInteraction, { uid }: { uid: Account }) {
  await respond(interaction).send({ content: `Account of ${uid.name}` })
}

The schema checks the uid's format first, so the pipe only ever sees a well-formed one. pipes maps a key to one pipe or to several, applied in order, each receiving the previous one's result.

One instance of a pipe serves every call, so it can inject services, as AccountPipe does, and hold a cache. A pipe that throws stops the call, and its error reaches the exception filters. To give one use of a pipe its settings, pass { provide, params }, read with context.getParams() from transform's second argument.

A pipe without a schema

@UsePipe(key, ...pipes) runs pipes on one value without a schema, or after @Validate's own pipes. @Validate can't see a separate @UsePipe, so mark the value that pipe produces Piped<T> in the handler's params; inside the handler it's exactly T. A pipe whose output doesn't fit the parameter fails to compile.

Which handlers take them

@Validate and @UsePipe apply to command, component and modal handlers, and to message handlers with a pattern. Autocomplete, reaction and event handlers, and a message handler for every message, don't take them: the bot refuses to start with either on them.

Gotchas

  • Two @Validate on one handler throw as the decorator applies. Combine the schemas.
  • A guard sees the raw input. Validation runs after the guards, so a guard reading getHandlerParams() gets the values before the schema's defaults and coercions.
  • Mark a separate pipe's output Piped<T>. Without it, the handler's params don't match the schema's output and the code doesn't compile.

Next steps

  • Interceptors: see the input before and after validation.
  • Exception filters: word ValidationError's issues your own way, or in the user's language.
  • Cooldowns: limit how often valid input can run the handler.