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.jsonto get a generatedeslint.config.jsas 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 0so 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:
| Config | Description |
|---|---|
eslint-config-airbnb | Airbnb’s opinionated rules (legacy .eslintrc only; v9 fork: eslint-config-airbnb-extended) |
eslint-config-standard | Standard JS style |
eslint-config-xo | Strict but practical |
@antfu/eslint-config | Anthony Fu’s flat-config-native preset, very modern |
@vercel/style-guide | Vercel’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:
| Type | Behaviour | Flag |
|---|---|---|
| Safe fixes | Mechanically correct, no semantics change | --fix |
| Suggestion fixes | Probably correct, may change behaviour | --fix-type suggestion |
| Layout fixes | Whitespace/style only | --fix-type layout |
| Problem fixes | Code 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-varsautofix 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.
| Concern | ESLint (v9 flat config) | Biome |
|---|---|---|
| Languages | JS, TS, JSX, TSX (via plugins: Vue, Svelte, MD, YAML) | JS, TS, JSX, TSX, JSON, CSS, GraphQL |
| Formatter included | No — pair with Prettier | Yes |
| Plugin ecosystem | Hundreds of plugins, 15+ years of rules | Limited (WASM plugins in v2, early) |
| Cold-start speed (1k files) | 5–15 s | 0.1–0.5 s |
| Config files | eslint.config.js + .prettierrc | biome.json |
| Type-aware linting | Yes (typescript-eslint) | No (parses syntax only) |
| React Hooks rules | Yes (eslint-plugin-react-hooks) | Partial (useExhaustiveDependencies) |
| Editor support | Mature — VS Code, IntelliJ, Vim | Good — 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,importthe config and spread/concat it into the array. parserOptions.projectmakes lint 10× slower — type-aware rules requiretsc-level type inference for every file. Scope to source only, or useprojectService: 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
.tsx—files: ["**/*.ts"]does NOT match.tsx. Usefiles: ["**/*.{ts,tsx}"]or["**/*.ts", "**/*.tsx"]. --fixmodifies generated files — make suredist/,build/,.next/, andcoverage/are in a globalignoresblock.no-unused-varsflags React imports — setvarsIgnorePattern: "^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. overridesis gone in flat config — what used to beoverrides: [{ files, rules }]is now a separate top-level object in the array. Don’t translate the legacy shape literally.extendsis 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)
Print every rule actually applied to a file#
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-vitestfor test-file lints - TypeScript installation — required for
typescript-eslint