Skip to content

Linting

Catch bot mistakes in your editor with the shared ESLint config and its seedcord and discord.js rules. Covers the config file, what each rule set catches, turning one rule off, the other options, formatting, and adding all of it to an existing project.

Some bot mistakes compile cleanly and only fail when a user clicks. A handler without its route decorator never answers, and a builder with a name Discord rejects fails when you deploy. The seedcord and discord.js lint rules report both in your editor, before the bot ever starts.

The scaffold writes eslint.config.ts for you, plus two scripts that run ESLint over your src folder.

pnpm run lint

lint:fix runs the same rules and writes back the fixes it can apply.

The config file

Setting up typescript-eslint, import ordering, and several more plugins by hand takes a long config file that you then maintain. The scaffold's file is one call instead.

eslint.config.ts
import createConfig from '@seedcord/eslint-config';

export default createConfig({
    tsconfigRootDir: import.meta.dirname,
    registerDiscordjsPlugin: true,
    registerSeedcordPlugin: true,
    generalIgnores: ['**/seedcord-gen.d.ts']
});

createConfig returns a flat config array, so ESLint reads it directly. By default it turns on the typescript-eslint presets, import ordering, TSDoc syntax checking, unicorn, the security plugin, and prettier compatibility. tsconfigRootDir points the type-aware rules at your tsconfig.json. import.meta.dirname is the folder holding eslint.config.ts. That value is right whenever your tsconfig.json is in the same folder as eslint.config.ts.

registerDiscordjsPlugin and registerSeedcordPlugin add the rules written for bots. If you leave either one out, its rules never run. The next two sections list what each one brings in.

What the seedcord rules catch

These read your handlers, commands, events, and subscribers. Some catch a class that never registers. The rest catch a call that bypasses the framework.

rulewhat it catches
interaction-handler-missing-routea handler missing its route decorator. Its clicks reach the unhandled default
command-builder-missing-register-commanda command missing @RegisterCommand, which keeps it from deploying to Discord
event-handler-missing-register-eventan event handler missing @RegisterEvent, so it never fires. Gateway only
middleware-missing-register-decoratormiddleware missing @Middleware. It never runs on a request
subscriber-missing-decoratorsa subscriber missing @Subscribe, or a WebhookLog missing @WebhookUrl
no-raw-client-eventsa client.on listener, which skips the dispatcher and every middleware on it. Gateway only
no-raw-interaction-acksa raw discord.js reply, defer, update, or showModal on a handler's interaction
no-djs-builder-importa component builder imported from discord.js. Only the @discordjs/builders copy survives nesting
use-custom-id-codeca hand-written setCustomId string, which drifts from the route that reads it
use-paint-in-logschalk inside a logger call. A terminal theme remaps chalk's color names

What the discord.js rules catch

Builders get their own set. Most of these rules stop a payload Discord would reject, or one discord.js refuses to build.

rulewhat it catches
valid-command-namea name outside Discord's rules: lowercase letters, digits, hyphens, underscores, 1 to 32 characters
required-option-before-optionala required slash option declared after an optional one
no-choices-and-autocompleteautocomplete and choices on the same option, which throws a RangeError
no-discord-limit-exceededmore items than a builder allows, whenever the count is written in the code
require-button-propsa button missing the props that its style requires, like a url on a Link button
no-conflicting-button-propsa button carrying props that its style forbids, like a customId next to a url
select-menu-min-exceeds-maxa select menu whose minimum selections go above its maximum
require-components-v2-flagv2 components sent without MessageFlags.IsComponentsV2
no-mixed-message-formatbuilder components mixed with content, embeds, poll, or stickers
prefer-ephemeral-flagthe deprecated ephemeral option. lint:fix rewrites it to flags
prefer-v2-componentan embed where a container and a text display fit

prefer-ephemeral-flag and prefer-v2-component report as warnings, since both describe a preference. Every other rule on this page reports as an error.

Turning one off

Sometimes a rule fits most of your bot and not one folder, like old code you haven't moved to v2 components yet. The seedcord rules carry a @seedcord/ prefix, and the discord.js rules carry discordjs/. The rule tables leave those prefixes out, so write the full name when you change a rule.

userConfigs goes in last, so anything you put there overrides the blocks above it. This one turns prefer-v2-component off for src/legacy only.

eslint.config.ts
export default createConfig({
    tsconfigRootDir: import.meta.dirname,
    registerDiscordjsPlugin: true,
    registerSeedcordPlugin: true,
    userConfigs: [
        {
            files: ['src/legacy/**/*.ts'],
            rules: { 'discordjs/prefer-v2-component': 'off' }
        }
    ]
});

The other options

Every one of these is optional. The defaults suit a normal bot. For the full set, including the Tailwind and MDX keys a web project would set, see SeedcordConfigOptions.

optiondefaultwhat it does
tsconfigRootDirthe working directorywhere the type-aware rules look for tsconfig.json
generalIgnores[]globs to skip, added to dist, node_modules, logs, and tests/temp
userConfigs[]your own config blocks, applied last
registerImportPlugin'all''fast' skips the cycle and deprecation checks. 'off' drops the plugin
registerUnicornPlugintrueunicorn requires ESLint 10.4, so set false on ESLint 9
registerSecurityPlugintruethe eslint-plugin-security recommended set
registerTsdocPlugintrueTSDoc syntax checking in your comments
registerTypescriptConfigstruethe typescript-eslint recommended, type-checked, strict, and stylistic presets. 'no-type-checked' keeps them and turns off the rules that read the type checker

Tip

When your lint run gets slow, setting registerTypescriptConfigs to 'no-type-checked' saves the most. Dropping the rules that read the type checker cuts about a fifth of the run time on the seedcord packages. registerImportPlugin: 'fast' removes the two rules that parse every file an import resolves to. That one saves closer to a tenth.

Formatting

Prettier formats your code and reads its own config file. The scaffold points that file at the shared prettier config, so formatting matches across seedcord projects.

prettier.config.ts
import { createPrettierConfig } from '@seedcord/eslint-config/prettier';

export default createPrettierConfig();

createPrettierConfig also takes per-glob overrides, and a tailwind option for web projects that sort Tailwind classes.

prettier.config.ts
export default createPrettierConfig({
    overrides: [{ files: '*.md', options: { proseWrap: 'always' } }]
});

createConfig already applies eslint-config-prettier, which switches off every ESLint rule that overlaps prettier's formatting. You don't install it or add it to userConfigs.

Starting from an existing project

pnpm add -D @seedcord/eslint-config eslint jiti typescript

@seedcord/eslint-config brings the seedcord and discord.js rule plugins with it, so that one line installs everything. ESLint loads a TypeScript config file through jiti. Add the eslint.config.ts from the top of this page along with the two scripts.

package.json
{
    "scripts": {
        "lint": "eslint 'src/**/*.ts' --cache",
        "lint:fix": "eslint 'src/**/*.ts' --fix --cache"
    }
}