ESLint matches source against a rule set written over the syntax tree: unused
bindings, shadowed names, unreachable code. Given type information it reaches
further — floating promises, any values flowing into places that assume something
narrower.
This page targets ESLint 9 (flat config) with TypeScript via
typescript-eslint. Every rule choice comes from the typescript-eslint
maintainers' canonical position (typescript-eslint maintainers n.d.)1:
Lint rules split into Logical (real verification), Stylistic (conventions), and Formatting (whitespace). Only the logical subset is verification work. Hand formatting to a formatter; hand stylistic conventions to a formatter where possible.
Setup¶
A Prettier-style formatter is a separate dependency
(npm install --save-dev prettier) and lives in a separate
config (.prettierrc.json). The ESLint config here assumes
Prettier handles formatting; ESLint handles logic.
Recommended eslint.config.js baseline¶
// eslint.config.js — ESLint 9 flat config
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import globals from "globals";
export default tseslint.config(
// Files to lint.
{
files: ["src/**/*.{ts,tsx,js,jsx}", "tests/**/*.{ts,js}"],
ignores: ["dist/**", "build/**", "coverage/**", "**/*.d.ts"],
},
// ESLint core: logical rules only.
js.configs.recommended,
// typescript-eslint: type-checked recommendations.
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
// Project-wide overrides.
{
languageOptions: {
parserOptions: {
projectService: true, // ESLint 9 type-aware
tsconfigRootDir: import.meta.dirname,
},
globals: { ...globals.browser, ...globals.node },
},
rules: {
// ===== Logical rules worth keeping =====
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-unnecessary-condition": "warn",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// ===== Stylistic rules we keep (small, named set) =====
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/array-type": ["error", { default: "array" }],
// ===== Disabled — formatting belongs to Prettier =====
"@typescript-eslint/quotes": "off",
"@typescript-eslint/semi": "off",
"@typescript-eslint/indent": "off",
"@typescript-eslint/comma-dangle": "off",
},
},
// Test-file relaxations.
{
files: ["tests/**/*", "**/*.test.ts", "**/*.spec.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
);
typescript-eslint¶
@typescript-eslint/no-floating-promises. APromisewhose result is never awaited is almost always a bug — the caller has lost the error path, the value, or both.@typescript-eslint/no-misused-promises. Anasyncfunction passed where a sync callback is expected —if (asyncFn())tests a promise object, which is always truthy.@typescript-eslint/await-thenable.awaiton a value that isn't a promise. The cause is usually a missing call on the function that was meant to be awaited.@typescript-eslint/switch-exhaustiveness-check. Adding a new sum-type variant and forgetting to handle it in one of the existingswitchstatements is exactly what types are supposed to catch.
eslint-plugin-react-hooks¶
LLMs systematically violate React Hooks constraints.
eslint-plugin-react-hooks catches these cheaply at lint time.
eslint-plugin-regexp¶
Catches regexp bugs LLMs write: duplicate alternatives,
character-class errors, and (via no-super-linear-backtracking /
no-super-linear-move) a cheap static heuristic for super-linear
backtracking. That heuristic is a first pass, not a real ReDoS check:
for that, eslint-plugin-redos (powered by
recheck) runs actual complexity analysis — sound but
costly (seconds per pattern, 10 s default timeout). See
ReDoS detection
for the cross-language picture.
Other plugins worth enabling¶
- React — eslint-plugin-react-x and
eslint-plugin-react-dom (the
eslint-reactproject; correctness rules, not just style), plus eslint-plugin-jsx-a11y for accessibility. - Node — eslint-plugin-n: imports that won't
resolve, Node built-ins/APIs missing from your supported
engines, deprecated APIs — "will it actually run" checks. - Test runners — official plugins for
Vitest (
@vitest/eslint-plugin), eslint-plugin-playwright, and eslint-plugin-testing-library. - Bug-finding — eslint-plugin-sonarjs for its logical rules (identical branches, identical conditions, redundant boolean logic — copy-paste and logic bugs). Its cognitive-complexity rule is a heuristic signal, not verification; treat it as advisory.
Rules to skip / why¶
max-len,max-lines,max-params— heuristics for human-readable code; Prettier covers most line-width questions. The other two trigger refactors that are usually worse than what they replaced.no-consoleaserror. Usewarn; lots of legitimate debug usage flows throughconsole. The strict version produces "fix the lint" PRs that only add eslint-disable comments.prefer-constaserror— fine aswarn. Aswarnit surfaces; aserrorit forces churn.- All
airbnb-config-style imports — these mix logical, stylistic, and formatting rules under one umbrella, which is exactly what the trichotomy says not to do. Cherry-pick what you want.
Formatting belongs to Prettier (or dprint)¶
A formatter is a pretty-printer: input → single canonical output, deterministically, every time. ESLint's formatting rules duplicate that work, more slowly, with configuration arguments. Hand whitespace, semicolons, quotes, trailing commas, brace placement to Prettier or dprint.
Recommended .prettierrc.json:
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5", // or "all" — the default in latest Prettier
"printWidth": 80,
"tabWidth": 2
}
trailingComma: "es5" reduces diff noise — adding a new entry
to an array or object doesn't show the previous line as modified
just to gain a trailing comma.
Diff-time deployment, not nightly¶
The largest empirical finding on static-analysis adoption is that diff-time deployment beats batch deployment; the evidence is on static analysis. Run ESLint as a PR-time gate, not as a nightly batch job that nobody triages.
GitHub Actions example:
# .github/workflows/lint.yml
name: lint
on:
pull_request:
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx eslint --max-warnings=0 .
--max-warnings=0 makes warnings fail the build. The trade-off:
either fix the warning or change the rule's severity in the
config. The middle ground (warning forever, never triaged) is
the failure mode this prevents.
When the team disagrees about a rule¶
- Is it logical, stylistic, or formatting?
- If formatting — move to Prettier; the question is settled.
- If stylistic — pick one, document the choice in a comment
in
eslint.config.js, stop arguing. - If logical — read the rule's documented examples. Decide per the intent of the rule, not per the most recent PR that triggered it.
The classification step typically resolves the disagreement before reaching steps 3 or 4.
Referenced by¶
- Dead-code detection · Methods
- Linters · Methods
- Verifying time and date handling · Methods
- Recipes · Recipes
- TypeScript · Recipes
References¶
-
typescript-eslint maintainers. n.d. What About Formatting? Typescript-eslint Documentation. https://typescript-eslint.io/users/what-about-formatting/. ↩