Skip to content

Software Quality

ESLint

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

npm install --save-dev eslint typescript-eslint @eslint/js

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.

// 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. A Promise whose 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. An async function passed where a sync callback is expected — if (asyncFn()) tests a promise object, which is always truthy.
  • @typescript-eslint/await-thenable. await on 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 existing switch statements 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

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-console as error. Use warn; lots of legitimate debug usage flows through console. The strict version produces "fix the lint" PRs that only add eslint-disable comments.
  • prefer-const as error — fine as warn. As warn it surfaces; as error it 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

  1. Is it logical, stylistic, or formatting?
  2. If formatting — move to Prettier; the question is settled.
  3. If stylistic — pick one, document the choice in a comment in eslint.config.js, stop arguing.
  4. 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

References


  1. typescript-eslint maintainers. n.d. What About Formatting? Typescript-eslint Documentation. https://typescript-eslint.io/users/what-about-formatting/