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 lintlint: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.
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.
| rule | what it catches |
|---|---|
interaction-handler-missing-route | a handler missing its route decorator. Its clicks reach the unhandled default |
command-builder-missing-register-command | a command missing @RegisterCommand, which keeps it from deploying to Discord |
event-handler-missing-register-event | an event handler missing @RegisterEvent, so it never fires. Gateway only |
middleware-missing-register-decorator | middleware missing @Middleware. It never runs on a request |
subscriber-missing-decorators | a subscriber missing @Subscribe, or a WebhookLog missing @WebhookUrl |
no-raw-client-events | a client.on listener, which skips the dispatcher and every middleware on it. Gateway only |
no-raw-interaction-acks | a raw discord.js reply, defer, update, or showModal on a handler's interaction |
no-djs-builder-import | a component builder imported from discord.js. Only the @discordjs/builders copy survives nesting |
use-custom-id-codec | a hand-written setCustomId string, which drifts from the route that reads it |
use-paint-in-logs | chalk 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.
| rule | what it catches |
|---|---|
valid-command-name | a name outside Discord's rules: lowercase letters, digits, hyphens, underscores, 1 to 32 characters |
required-option-before-optional | a required slash option declared after an optional one |
no-choices-and-autocomplete | autocomplete and choices on the same option, which throws a RangeError |
no-discord-limit-exceeded | more items than a builder allows, whenever the count is written in the code |
require-button-props | a button missing the props that its style requires, like a url on a Link button |
no-conflicting-button-props | a button carrying props that its style forbids, like a customId next to a url |
select-menu-min-exceeds-max | a select menu whose minimum selections go above its maximum |
require-components-v2-flag | v2 components sent without MessageFlags.IsComponentsV2 |
no-mixed-message-format | builder components mixed with content, embeds, poll, or stickers |
prefer-ephemeral-flag | the deprecated ephemeral option. lint:fix rewrites it to flags |
prefer-v2-component | an 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.
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.
| option | default | what it does |
|---|---|---|
tsconfigRootDir | the working directory | where 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 |
registerUnicornPlugin | true | unicorn requires ESLint 10.4, so set false on ESLint 9 |
registerSecurityPlugin | true | the eslint-plugin-security recommended set |
registerTsdocPlugin | true | TSDoc syntax checking in your comments |
registerTypescriptConfigs | true | the 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.
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.
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.
{
"scripts": {
"lint": "eslint 'src/**/*.ts' --cache",
"lint:fix": "eslint 'src/**/*.ts' --fix --cache"
}
}