ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project
A default ESLint config has 80 rules and most of them are noise. The ones worth their CI cost catch real bugs — async without await, exhaustive switches, no-floating-promises. Here are the twelve I turn on for every TypeScript project, and the four I turn off because they cause more harm than good.
A default ESLint config catches semicolon mistakes and unused variables. That is fine but not life-changing. The rules that actually earn their CI cost are the ones that catch bugs no test will catch — async functions silently swallowed, missing switch cases, type casts that hide an error. They turn lint from a style check into a quiet bug detector.
This post is twelve ESLint rules I enable on every TypeScript project, the four I disable because they fight productivity more than they help, and the ESLint-config approach that survives a quarterly framework update.
The twelve rules that pay rent
1. @typescript-eslint/no-floating-promises
async function notifyUser() { /* ... */ }
// Lint error: floating promise.
notifyUser();
Forgetting await on an async call is one of the most common Node.js bugs. The promise resolves later, the function returns immediately, errors disappear into the void. This rule forces every promise to be awaited or explicitly discarded with void:
await notifyUser();
// or, fire-and-forget:
void notifyUser();
Single best rule on the list.
2. @typescript-eslint/await-thenable
The opposite mistake — awaiting something that isn’t a promise:
const x = await 42; // lint error: not a thenable
Catches typos like await user when you meant await getUser(user).
3. @typescript-eslint/no-misused-promises
// Lint error: passing async function to a void callback.
button.addEventListener('click', async () => { await save(); });
The async function returns a promise the callback site does not handle. Errors disappear. The rule forces you to either wrap the call (() => { void save(); }) or fix the API (use a handler that knows about promises).
4. @typescript-eslint/switch-exhaustiveness-check
type Status = 'pending' | 'running' | 'done';
function describe(s: Status): string {
switch (s) {
case 'pending': return 'waiting';
case 'running': return 'in progress';
// missing 'done' — lint error.
}
}
When a new variant is added to the union, every switch using that union becomes a lint error until you handle it. Force-multiplies refactor confidence.
5. @typescript-eslint/no-unnecessary-condition
if (user) { ... } // user's type is `User`, not `User | undefined` — always true.
Catches dead branches caused by mis-typed code. Common when a function’s return type changes and the callers no longer need null checks.
6. @typescript-eslint/strict-boolean-expressions
Forbid coercing values to boolean implicitly:
if (name) { ... } // lint error if name is `string` — empty string vs undefined?
Forces you to be explicit: if (name !== ''), if (name != null). Annoying for two days, prevents the “0 is falsy” bug for life.
7. eqeqeq (built-in)
if (x == null) { ... } // ok with allow-null exception; otherwise force ===.
== does coercion. === does not. Use === everywhere except the one case x == null (which matches both null and undefined).
8. @typescript-eslint/no-explicit-any
function f(x: any) { ... } // lint error.
Treat any as a code smell. When it is truly needed, the comment-disable is loud and reviewable. Pair with @typescript-eslint/no-unsafe-* rules for completeness.
9. no-console (with exceptions)
console.log('debug'); // lint error in non-test files.
Use a real logger. Save console.log for tests and scripts via overrides:
{
"overrides": [
{ "files": ["**/*.test.ts", "scripts/**"], "rules": { "no-console": "off" } }
]
}
10. @typescript-eslint/prefer-nullish-coalescing
const port = config.port || 3000;
|| falls through on 0, '', false. If config.port = 0 is intentional, you get 3000. Use ??:
const port = config.port ?? 3000; // only falls through on null/undefined.
11. import/no-cycle
// a.ts imports b.ts which imports a.ts — circular.
Circular imports cause non-deterministic behavior at module init time. The rule catches them. Painful in a large legacy codebase; worth it.
12. unicorn/no-array-for-each (or similar)
arr.forEach(x => transform(x)); // suggest: for (const x of arr) ...
forEach is harder to debug, harder to break out of, and slightly slower. for...of is a strict upgrade. Lots of teams disagree on this one — it is the most opinionated of the twelve. Take it or leave it.
The four rules to turn off
Some popular rules waste time more than they save it.
max-lines-per-function. A 60-line function is fine if it is doing one thing clearly. The rule encourages chopping into worse abstractions.
@typescript-eslint/explicit-function-return-type. TypeScript already infers; explicit returns clutter local helpers without preventing bugs. Enable only at exported API boundaries via overrides.
prettier/prettier mixed with formatting rules. Pick Prettier or ESLint formatting, not both. Modern setups run Prettier separately.
sort-keys. Forces alphabetical key ordering, which fights logical grouping. The cost outweighs the benefit.
A working config
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": "./tsconfig.json" },
"plugins": ["@typescript-eslint", "import", "unicorn"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:import/recommended",
"plugin:import/typescript"
],
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/strict-boolean-expressions": ["error", { "allowNullableObject": true }],
"eqeqeq": ["error", "always", { "null": "ignore" }],
"@typescript-eslint/no-explicit-any": "error",
"no-console": ["error", { "allow": ["warn", "error"] }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"import/no-cycle": "error",
"max-lines-per-function": "off",
"@typescript-eslint/explicit-function-return-type": "off"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/__tests__/**", "scripts/**"],
"rules": {
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}
]
}
The two recommended-* extends do most of the heavy lifting; the explicit rules above are the ones I know are worth flipping above defaults.
Type-aware rules need parserOptions.project
Half of the most useful rules (no-floating-promises, await-thenable, no-unnecessary-condition) require type information. They only work if parserOptions.project points at your tsconfig.json. Without it, the rules silently disable themselves and you get no errors.
Cost: ESLint runs slower because it does a TypeScript compile. About 5× slower. For most projects this is fine — lint runs in seconds, not minutes. If you hit a wall, projectService (newer ESLint plugin feature) caches the project graph between files.
Where to run lint
Three places, three different priorities:
- Editor. Fast feedback while typing. Most rules enabled. Errors as you save.
- Pre-commit hook (lint-staged).
eslint --fixon staged files only. Fast. - CI. Full lint pass on every file. Strict mode. Failure blocks merge.
Don’t lint in production. Don’t lint at runtime. Lint at code-write time, code-commit time, and code-review time. After that, the artifact is the artifact.
When to disable a rule
The right disable is local and explained:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- third-party type def is wrong
const result = (lib as any).callUntyped();
The comment explains why, not that. Reviewers can decide whether the workaround is real. A disable-next-line with no comment is a smell — fix the underlying issue or improve the type.
/* eslint-disable */ for the whole file is almost always wrong. The exception is generated code where rules do not apply.
The takeaway
A small, sharp ESLint config catches a class of bugs that the type checker and tests do not. Async-related rules are the highest-value group — no-floating-promises alone catches more bugs than the rest of the list combined. Switch exhaustiveness, strict boolean checks, and nullish-coalescing close most of the remaining gaps.
Avoid the productivity-fighter rules (function length, alphabetical ordering, mandatory return types) — they take more than they give. Run lint in your editor, pre-commit, and CI. Disable locally with a comment explaining the why.
The team that adopts this config notices, within a month, that one whole class of bugs has disappeared from production. That is what a good lint pass earns you.
A note from Yojji
The kind of code-quality discipline that catches a class of bugs at lint time — async-without-await, missing switch cases, accidental any — is the kind of long-haul engineering hygiene that decides whether a codebase still feels good at year three. It is the kind of detail Yojji’s teams put into the products they ship for clients.
Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, GCP), and full-cycle product engineering — including the developer-tooling decisions that compound into long-term codebase health.