skip to content

ESLint

The standard JavaScript/TypeScript linter. Covers flat config (v9), legacy .eslintrc, running, rules, plugins, TypeScript integration, VS Code, and pre-commit hooks.

14 min read 38 snippets deep dive

ESLint#

What it is#

ESLint is the standard JavaScript and TypeScript linter. It finds bugs, enforces code style, and applies configurable rules via a plugin ecosystem. ESLint v9 (released April 2024) switched to flat config (eslint.config.js) by default; v8 uses the legacy .eslintrc.* format.

Install#

# npm
npm install -D eslint

# Create a config interactively (recommended for new projects)
npm init @eslint/config@latest

Output:

✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · yes
✔ Where does your code run? · node
The config that you've selected requires the following dependencies:
eslint, @eslint/js, typescript-eslint
✔ Would you like to install them now? · Yes

Flat config — eslint.config.js (v9, default)#

Minimal JavaScript config#

// eslint.config.js
import js from "@eslint/js";

export default [
  js.configs.recommended,
  {
    rules: {
      "no-unused-vars": "warn",
      "no-console": "warn",
      eqeqeq: "error",
    },
  },
];

Full TypeScript config#

// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import globals from "globals";

export default tseslint.config(
  // Apply ESLint recommended rules to all JS/TS files
  js.configs.recommended,

  // Apply TypeScript-ESLint recommended rules to .ts/.tsx files
  ...tseslint.configs.recommended,

  {
    languageOptions: {
      globals: {
        ...globals.node,
        ...globals.browser,
      },
    },
    rules: {
      "@typescript-eslint/no-explicit-any": "warn",
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
      "no-console": ["warn", { allow: ["warn", "error"] }],
      eqeqeq: "error",
      curly: "error",
    },
  },

  // Ignore generated files and dependencies
  {
    ignores: ["dist/**", "build/**", "node_modules/**", "*.min.js"],
  }
);

React + TypeScript config#

// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    plugins: {
      react: reactPlugin,
      "react-hooks": reactHooks,
    },
    languageOptions: {
      globals: globals.browser,
      parserOptions: { ecmaFeatures: { jsx: true } },
    },
    settings: { react: { version: "detect" } },
    rules: {
      ...reactPlugin.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
      "react/react-in-jsx-scope": "off", // Not needed in React 17+
    },
  },
  { ignores: ["dist/**", "node_modules/**"] }
);

Legacy config — .eslintrc.* (v8)#

// .eslintrc.json
{
  "env": { "node": true, "es2022": true },
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": "error",
    "eqeqeq": "error"
  },
  "ignorePatterns": ["dist/", "node_modules/"]
}

[!TIP] If you are on v8 but want to migrate to v9 flat config, run npx @eslint/migrate-config .eslintrc.json to get a generated eslint.config.js as a starting point.

Running ESLint#

# Lint all files
npx eslint .

# Lint a specific directory
npx eslint src/

# Lint and auto-fix fixable issues
npx eslint src/ --fix

# Fail if there are any warnings (useful in CI)
npx eslint . --max-warnings 0

# Output as JSON (for tooling)
npx eslint . --format json > eslint-report.json

# Lint specific file types
npx eslint "src/**/*.{js,ts,jsx,tsx}"

# Print the resolved config for a file (debug)
npx eslint --print-config src/index.ts

Output (typical lint run):

/home/user/project/src/app.ts
  12:5  warning  Unexpected console statement  no-console
  34:3  error    Missing semicolon             semi

✖ 2 problems (1 error, 1 warning)
  0 errors and 1 warning potentially fixable with the `--fix` option.

Rule severity levels#

// In the rules object:
"rule-name": 0          // off
"rule-name": 1          // warn
"rule-name": 2          // error

// String equivalents (preferred for readability):
"rule-name": "off"
"rule-name": "warn"
"rule-name": "error"

// With options — use an array:
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
"no-console": ["warn", { "allow": ["warn", "error"] }]

Key plugins#

# TypeScript
npm install -D typescript-eslint

# React
npm install -D eslint-plugin-react eslint-plugin-react-hooks

# Import order
npm install -D eslint-plugin-import

# Node.js best practices
npm install -D eslint-plugin-n

# Accessibility (jsx-a11y)
npm install -D eslint-plugin-jsx-a11y

# Unicorn (extra opinionated rules)
npm install -D eslint-plugin-unicorn

Output: (none — exits 0 on success)

eslint-plugin-import example#

// eslint.config.js (flat config)
import importPlugin from "eslint-plugin-import";

export default [
  {
    plugins: { import: importPlugin },
    rules: {
      "import/no-duplicates": "error",
      "import/order": [
        "warn",
        {
          groups: ["builtin", "external", "internal", "parent", "sibling"],
          "newlines-between": "always",
          alphabetize: { order: "asc" },
        },
      ],
    },
  },
];

Inline rule disabling#

// Disable next line
// eslint-disable-next-line no-console
console.log("debug");

// Disable current line
doSomething(); // eslint-disable-line no-alert

// Disable a block
/* eslint-disable no-console */
console.log("a");
console.log("b");
/* eslint-enable no-console */

// Disable entire file (put at top)
/* eslint-disable */

VS Code integration#

Install the ESLint extension (dbaeumer.vscode-eslint), then add to .vscode/settings.json:

{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
  "eslint.useFlatConfig": true
}

Pre-commit hooks with lint-staged#

npm install -D husky lint-staged
npx husky init

Output: (none — exits 0 on success)

Add to package.json:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix --max-warnings 0",
      "prettier --write"
    ]
  }
}

Add to .husky/pre-commit:

npx lint-staged

Output: (none — exits 0 on success)

package.json scripts#

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "lint:ci": "eslint . --max-warnings 0"
  }
}

CI usage (GitHub Actions)#

- name: Lint
  run: npx eslint . --max-warnings 0

[!TIP] In CI always use --max-warnings 0 so that warnings become pipeline failures. In local development, warnings are fine as soft guidance.

Flat config — deep dive#

Flat config replaces the legacy .eslintrc cascade with a single array of configuration objects. Each object can apply to all files, a subset (via files), or be a pure ignore record. ESLint evaluates the array top-to-bottom and merges objects whose files glob matches the file being linted — the last writer wins for rules, languageOptions, and plugins. This makes overrides explicit (no extends-graph chasing) and removes the need for .eslintignore — ignores live inside the config.

Anatomy of a flat config object#

// eslint.config.js
export default [
  {
    name: "my-rules",                      // optional label (shows in --print-config)
    files: ["**/*.{ts,tsx}"],              // glob filter (default: all files ESLint sees)
    ignores: ["**/*.test.ts"],             // local ignore (applies only to this block)
    languageOptions: {
      ecmaVersion: 2024,                   // syntax version to parse
      sourceType: "module",                // "module" | "commonjs" | "script"
      parser: undefined,                   // override default espree parser
      parserOptions: { project: true },    // parser-specific options
      globals: { window: "readonly" },     // declared globals (no-undef)
    },
    linterOptions: {
      reportUnusedDisableDirectives: "warn", // flag stale /* eslint-disable */
      noInlineConfig: false,                 // forbid inline overrides
    },
    plugins: { unicorn: unicornPlugin },   // plugin namespace → object
    rules: {
      "unicorn/no-null": "warn",
    },
    settings: { react: { version: "detect" } }, // shared between plugins
  },
];

Ignore-only objects#

A config object containing only an ignores key applies globally — equivalent to the legacy .eslintignore. Mix this with per-block ignores for surgical exclusions:

export default [
  // Global ignores — never lint these
  { ignores: ["dist/**", "coverage/**", "**/*.min.js", "**/__generated__/**"] },

  // Block-local ignore — applies to following rules block only
  {
    files: ["**/*.ts"],
    ignores: ["**/*.d.ts"],
    rules: { "@typescript-eslint/no-unused-vars": "error" },
  },
];

Composing shared configs#

Tools like typescript-eslint and @eslint/js expose pre-built config arrays. Spread them into your own array, then layer overrides afterwards:

import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default [
  js.configs.recommended,
  ...tseslint.configs.strictTypeChecked,   // strict + type-aware rules
  ...tseslint.configs.stylisticTypeChecked,
  {
    languageOptions: { parserOptions: { project: "./tsconfig.json" } },
    rules: {
      "@typescript-eslint/consistent-type-imports": "error",
      "@typescript-eslint/no-misused-promises": "error",
    },
  },
];

tseslint.config(...) is a helper that performs the same array-flatten with TypeScript-friendly types — prefer it when you want IntelliSense on rule names.

Type-aware linting#

Some @typescript-eslint rules (e.g. no-floating-promises, no-misused-promises, await-thenable) need access to the TypeScript type checker. Enable them by pointing the parser at tsconfig.json:

import tseslint from "typescript-eslint";

export default tseslint.config({
  files: ["**/*.{ts,tsx}"],
  languageOptions: {
    parserOptions: {
      project: ["./tsconfig.json"],   // or projectService: true for v8+
      tsconfigRootDir: import.meta.dirname,
    },
  },
  extends: [...tseslint.configs.recommendedTypeChecked],
});

Type-aware linting is 5–10× slower than syntactic linting because each file requires program-wide type inference. Restrict the files pattern to source code, never include dist/, and consider running it only in CI if dev-loop latency matters.

Plugin ecosystem deep dive#

Plugins extend ESLint with new rules, configs, processors, and parsers. They register themselves under a namespace inside plugins and expose rules under that namespace (e.g. react/no-unused-prop-types).

typescript-eslint#

The canonical TypeScript plugin (a meta-package re-exporting @typescript-eslint/eslint-plugin and @typescript-eslint/parser). Provides the parser that understands TypeScript syntax and ~150 rules covering types, async, naming, unused code, and stylistic concerns. Three preset tiers: recommended (safe defaults), strict (more opinionated), and *TypeChecked variants that require the type-aware setup above.

import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.strict,
  {
    rules: {
      "@typescript-eslint/no-explicit-any": "error",
      "@typescript-eslint/consistent-type-definitions": ["error", "type"],
      "@typescript-eslint/array-type": ["error", { default: "array-simple" }],
      "@typescript-eslint/no-non-null-assertion": "error",
    },
  },
);

eslint-plugin-react and eslint-plugin-react-hooks#

React-specific lints: JSX accessibility scaffolding, prop-types vs TS, hooks rules-of-hooks enforcement, and exhaustive-deps for useEffect/useMemo/useCallback. The hooks plugin is mandatory for any React codebase — its react-hooks/exhaustive-deps rule catches the most common React bug (stale closures over state).

import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";

export default [
  {
    files: ["**/*.{jsx,tsx}"],
    plugins: {
      react,
      "react-hooks": reactHooks,
      "jsx-a11y": jsxA11y,
    },
    settings: { react: { version: "detect" } },
    rules: {
      ...react.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
      ...jsxA11y.configs.recommended.rules,
      "react/react-in-jsx-scope": "off",
      "react/prop-types": "off",            // using TypeScript instead
      "react-hooks/exhaustive-deps": "error",
    },
  },
];

eslint-plugin-import (and eslint-plugin-import-x)#

Lints ES module import/export syntax: detects circular dependencies, missing files, unresolved paths, duplicate imports, and enforces import ordering. eslint-plugin-import-x is a faster fork actively maintained for flat config.

import importX from "eslint-plugin-import-x";

export default [
  importX.flatConfigs.recommended,
  importX.flatConfigs.typescript,
  {
    rules: {
      "import-x/no-cycle": ["error", { maxDepth: 5 }],
      "import-x/no-unresolved": "error",
      "import-x/order": [
        "warn",
        {
          groups: [
            "builtin",
            "external",
            "internal",
            ["parent", "sibling", "index"],
            "type",
          ],
          "newlines-between": "always",
          alphabetize: { order: "asc", caseInsensitive: true },
        },
      ],
    },
  },
];

eslint-plugin-security#

Static analysis for common Node.js security pitfalls: unsafe regex (ReDoS), eval(), child_process with user-controlled args, path traversal via fs calls, and pseudo-random number generators where crypto is needed. False-positive rate is moderate — review findings rather than auto-fixing.

import security from "eslint-plugin-security";

export default [
  security.configs.recommended,
  {
    rules: {
      "security/detect-object-injection": "off",  // very noisy in real code
    },
  },
];

eslint-plugin-n (Node.js)#

Successor to eslint-plugin-node. Enforces Node-specific best practices: importing only declared dependencies, preferring node: protocol imports (import fs from "node:fs"), no use of deprecated APIs, and no use of features above the version declared in engines.node.

eslint-plugin-unicorn#

Opinionated quality rules: prefer Array.from, prefer String.replaceAll, prefer Number.parseInt, prefer top-level await, no process.exit(), prefer node: imports, consistent file naming. Highly stylistic — pick the rules you agree with rather than enabling recommended wholesale.

Shareable configs#

A shareable config is just an npm package that exports a flat config array. Convention: name them eslint-config-<n> (then import as <n>) or @scope/eslint-config-<n>. Popular examples:

ConfigDescription
eslint-config-airbnbAirbnb’s opinionated rules (legacy .eslintrc only; v9 fork: eslint-config-airbnb-extended)
eslint-config-standardStandard JS style
eslint-config-xoStrict but practical
@antfu/eslint-configAnthony Fu’s flat-config-native preset, very modern
@vercel/style-guideVercel’s preset for Next.js projects
// Using @antfu/eslint-config
import antfu from "@antfu/eslint-config";

export default antfu({
  typescript: true,
  vue: false,
  react: true,
  stylistic: { indent: 2, quotes: "double", semi: true },
});

Autofix — --fix mechanics and safety#

eslint --fix rewrites source files in place by applying each rule’s fix function. Not every rule is auto-fixable; those that are usually annotate themselves with the wrench icon in their documentation. Autofixes are categorized by ESLint:

TypeBehaviourFlag
Safe fixesMechanically correct, no semantics change--fix
Suggestion fixesProbably correct, may change behaviour--fix-type suggestion
Layout fixesWhitespace/style only--fix-type layout
Problem fixesCode may have been buggy--fix-type problem
# Only apply layout fixes (no semantic edits)
npx eslint . --fix --fix-type layout

# Show what would be fixed without writing
npx eslint . --fix-dry-run --format json

# Fix only one rule
npx eslint . --fix --rule '{"semi": "error"}' --no-eslintrc

Output (dry run):

[{"filePath":"/proj/src/app.ts","messages":[],"output":"const x = 1;\n","fixableErrorCount":1,"fixableWarningCount":0}]

[!WARNING] Always commit (or stash) before running --fix. The autofixer for some rules (e.g. no-unused-vars autofix from third-party plugins) can delete code. Review the diff before staging.

Conflicting autofixes#

When two rules want to fix the same range, ESLint applies fixes in passes until the source stabilises (max 10 passes by default). Watch for oscillation: rule A rewrites to X, rule B rewrites back to Y, repeat. ESLint detects this and reports Unfixable due to conflicting rules. Common offenders are stylistic rules that conflict with Prettier — which is exactly why eslint-config-prettier exists.

ESLint vs Biome — when to use which#

Biome (biome) is a Rust-based linter + formatter aimed at replacing ESLint and Prettier with a single binary. It’s 10–100× faster, has zero plugin runtime, and ships rules compiled into the binary. The trade-off is plugin maturity.

ConcernESLint (v9 flat config)Biome
LanguagesJS, TS, JSX, TSX (via plugins: Vue, Svelte, MD, YAML)JS, TS, JSX, TSX, JSON, CSS, GraphQL
Formatter includedNo — pair with PrettierYes
Plugin ecosystemHundreds of plugins, 15+ years of rulesLimited (WASM plugins in v2, early)
Cold-start speed (1k files)5–15 s0.1–0.5 s
Config fileseslint.config.js + .prettierrcbiome.json
Type-aware lintingYes (typescript-eslint)No (parses syntax only)
React Hooks rulesYes (eslint-plugin-react-hooks)Partial (useExhaustiveDependencies)
Editor supportMature — VS Code, IntelliJ, VimGood — VS Code, Zed, Neovim

Choose ESLint when: you need type-aware rules, depend on a niche plugin (React-Query lints, Tailwind, GraphQL Schema), or have a deeply-customised rule set. Choose Biome when: you want one tool with one config, value speed, and your rule needs fit the recommended ruleset. Hybrid is also legitimate — run Biome for format + import sort, ESLint for type-aware rules only — but pay attention to the slowdown that re-enables.

Common pitfalls#

  • “Cannot find module ‘eslint-config-prettier’” after switching to flat config — extends: strings work only in legacy .eslintrc. In flat config, import the config and spread/concat it into the array.
  • parserOptions.project makes lint 10× slower — type-aware rules require tsc-level type inference for every file. Scope to source only, or use projectService: true (typescript-eslint v8+) which is lazier.
  • Flat config + IDE: nothing lints — older VS Code ESLint extensions need "eslint.useFlatConfig": true (now the default in recent versions, but still pinned in some settings).
  • Glob doesn’t match .tsxfiles: ["**/*.ts"] does NOT match .tsx. Use files: ["**/*.{ts,tsx}"] or ["**/*.ts", "**/*.tsx"].
  • --fix modifies generated files — make sure dist/, build/, .next/, and coverage/ are in a global ignores block.
  • no-unused-vars flags React imports — set varsIgnorePattern: "^React$" or use the new JSX transform (react/react-in-jsx-scope: off).
  • Mixed .eslintrc + eslint.config.js — ESLint v9 prefers flat config and ignores .eslintrc.* by default. Delete the legacy file once migrated to avoid confusion.
  • overrides is gone in flat config — what used to be overrides: [{ files, rules }] is now a separate top-level object in the array. Don’t translate the legacy shape literally.
  • extends is gone in flat config — spread the config array instead: [...sharedConfig, { rules: { ... } }].

Real-world recipes#

Lint only changed files in a pre-commit hook#

lint-staged already does this for git-staged paths. For a manual run against main:

git diff --name-only --diff-filter=ACMR origin/main \
  | grep -E '\.(js|jsx|ts|tsx)$' \
  | xargs --no-run-if-empty npx eslint --max-warnings 0

Output:

src/components/Card.tsx
  12:3  warning  Unexpected console statement  no-console

✖ 1 problem (0 errors, 1 warning)

Lint a monorepo with workspace-specific overrides#

// eslint.config.js at the repo root
import baseConfig from "./eslint.base.js";
import reactConfig from "./eslint.react.js";

export default [
  ...baseConfig,
  {
    files: ["apps/web/**/*.{ts,tsx}"],
    ...reactConfig[0],
  },
  {
    files: ["packages/server/**/*.ts"],
    languageOptions: { globals: { ...globals.node } },
  },
];

Cache lint results for fast re-runs#

# Cache lint results — only re-lint changed files
npx eslint . --cache --cache-location node_modules/.cache/eslint/

# Invalidate cache (after upgrading a plugin)
rm -rf node_modules/.cache/eslint/

Output: (first run lints everything; second run is near-instant)

npx eslint --print-config src/index.ts | jq '.rules | keys'

Output:

[
  "@typescript-eslint/consistent-type-imports",
  "@typescript-eslint/no-explicit-any",
  "no-console",
  "eqeqeq",
  ...
]

Generate a Markdown report from JSON output#

npx eslint . --format json \
  | jq -r '.[] | select(.errorCount + .warningCount > 0) | "- `\(.filePath | sub(".*/"; ""))` — \(.errorCount) errors, \(.warningCount) warnings"' \
  > lint-report.md

Output (lint-report.md):

- `app.ts` — 1 errors, 0 warnings
- `helpers.ts` — 0 errors, 3 warnings

Disable a rule for one folder only#

export default [
  ...baseConfig,
  {
    files: ["scripts/**/*.{js,ts}"],
    rules: {
      "no-console": "off",       // scripts/ may log freely
      "no-process-exit": "off",
    },
  },
];

See also#

  • Prettier — pair with ESLint via eslint-config-prettier
  • Biome — Rust-based all-in-one alternative
  • Vite — most modern bundler with first-class ESLint integration
  • Vitest — pairs with eslint-plugin-vitest for test-file lints
  • TypeScript installation — required for typescript-eslint