← All posts
·16 min read

Claude Code with ESLint: Config, Rules, and the Autofix Workflow

Claude CodeESLintTypeScriptWorkflow
Claude Code with ESLint: Config, Rules, and the Autofix Workflow

Why ESLint configs need their own CLAUDE.md section

ESLint is one of the most project-specific tools in the JS/TS ecosystem. Unlike TypeScript's compiler options (which are mostly universal) or Prettier's formatting rules (which are cosmetic), ESLint rules encode decisions that are unique to your team: import ordering conventions, naming patterns, security plugin requirements, banned APIs, complexity caps, and plugin configurations that shift meaning depending on your framework.

Claude Code knows ESLint well in the abstract. It can scaffold a flat config, extend a recommended set, add TypeScript support, and wire in React or Next.js plugins. What it cannot infer is your project's specific rule set. Without that context, Claude generates code against whatever ESLint baseline feels most common, which is almost never your baseline. The result is code that fails your lint pipeline on the first pnpm lint run: missing return types flagged by @typescript-eslint/explicit-function-return-type, import order violations from eslint-plugin-import, or no-console errors in code where you have exceptions carved out.

This is a fixable problem. The fix is telling Claude what your ESLint setup actually is before you ask it to write a single line.

If you are just getting Claude Code running on a new project, the Claude Code setup guide covers the installation and initial CLAUDE.md wiring. The ESLint section you are reading now assumes you already have Claude Code running and want to stop fighting your linter after every generation. For the broader picture of how CLAUDE.md shapes Claude Code output across your whole project, CLAUDE.md explained covers the mechanics.

The ESLint CLAUDE.md template

The CLAUDE.md at your project root is the primary channel for giving Claude the information it needs about your environment. For an ESLint-configured project, this section should declare: ESLint version, config format (flat config or legacy), which plugins are active, which rules are non-negotiable, how TypeScript integration is configured, whether Prettier is handling formatting, and where the config file lives so Claude can grep it when it needs specifics.

Here is a template covering the most common setup (ESLint v9 flat config, TypeScript via @typescript-eslint, Prettier for formatting):

# ESLint rules for Claude Code

## Setup
- ESLint v9.x with flat config (eslint.config.js or eslint.config.ts)
- @typescript-eslint/eslint-plugin v8.x
- @typescript-eslint/parser v8.x
- eslint-plugin-import v2.x (import ordering + no duplicate imports)
- eslint-plugin-react v7.x + eslint-plugin-react-hooks v4.x (React projects only)
- Prettier v3.x handles ALL formatting. No ESLint formatting rules active.
- Config file: eslint.config.js (project root)

## Config format
- Flat config (array export), NOT .eslintrc format
- Type-checked rules enabled via: tsconfig.json (project root)
- parseOptions: { project: "./tsconfig.json" }

## Must-not-disable rules (NEVER add eslint-disable-next-line for these)
- @typescript-eslint/no-explicit-any: error (use unknown or proper typing)
- @typescript-eslint/no-unsafe-argument: error (must type-check arguments)
- @typescript-eslint/no-unsafe-assignment: error
- @typescript-eslint/no-unsafe-call: error
- @typescript-eslint/no-unsafe-member-access: error
- @typescript-eslint/no-unsafe-return: error
- no-console: error (allowed: console.warn, console.error via overrides)
- react-hooks/rules-of-hooks: error
- react-hooks/exhaustive-deps: warn (fix deps array, never disable)

## Autofix preference
- Run: pnpm lint --fix before marking a task complete
- Claude should fix violations by rewriting code, not by adding eslint-disable comments
- If a rule genuinely cannot be satisfied without a disable, ask for review before adding it

## Custom rule discovery
- Run: grep -n "rules:" eslint.config.js to see project-specific overrides
- Run: grep -rn "@typescript-eslint" eslint.config.js for TS-specific config
- If unsure about a rule's current setting, grep the config before assuming default

## Naming conventions (enforced by ESLint or team convention)
- Components: PascalCase (MyComponent, not my-component or myComponent)
- Hooks: camelCase starting with use (useMyHook)
- Utilities: camelCase (formatDate, not FormatDate)
- Constants: SCREAMING_SNAKE_CASE for module-level constants
- Type names: PascalCase, no I prefix on interfaces

## Prettier integration
- Prettier owns: indentation, quotes, semicolons, trailing commas, line length
- ESLint does NOT configure: indent, quotes, semi, max-len, comma-dangle
- Do NOT add formatting rules to eslint.config.js. They will conflict with Prettier.
- Config: .prettierrc (project root), checked into version control

## Import rules
- Absolute imports use @ alias mapping from tsconfig.json paths
- No default imports from barrel files that break tree-shaking
- Import order: built-ins → external packages → @ aliases → relative paths
- eslint-plugin-import enforces order, run lint to check

Two sections here do disproportionate work.

The must-not-disable rules list matters because Claude's default response to a lint error it cannot immediately resolve is to add an eslint-disable-next-line comment and move on. This is the wrong response in almost every case. Disable comments accumulate, they disable rules for the wrong scope, and they teach Claude that suppressing the linter is acceptable. Once this list is in CLAUDE.md, Claude treats those rules as constraints that require actual code fixes rather than bypasses.

The custom rule discovery section turns Claude into an active reader of your config rather than a guesser. If Claude is writing code for a file and is unsure whether a particular rule is active, the grep commands let it check before writing something that violates the rule. This is especially useful for large projects where the ESLint config has grown complex over time and has per-directory overrides.

Flat config vs legacy .eslintrc: what Claude needs to know

ESLint v9 shipped the flat config format as the default. Legacy .eslintrc.* files still work in ESLint v9 via a compat layer, but the two formats are genuinely different in how they compose, how they scope rules to specific file paths, and how plugins are imported. Claude handles both, but it will mix them up without explicit guidance.

The most common problem: Claude scaffolds a flat config export but writes plugin declarations in the .eslintrc style, using string-based extends arrays that do not exist in flat config.

Flat config (ESLint v9+):

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

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,
  {
    plugins: {
      react,
      "react-hooks": reactHooks,
      import: importPlugin,
    },
    languageOptions: {
      parserOptions: {
        project: "./tsconfig.json",
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "react/react-in-jsx-scope": "off", // not needed with React 17+
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "warn",
      "import/order": [
        "error",
        {
          groups: ["builtin", "external", "internal", "parent", "sibling"],
          "newlines-between": "always",
          alphabetize: { order: "asc" },
        },
      ],
      "@typescript-eslint/no-explicit-any": "error",
    },
  },
  {
    // per-directory override: test files
    files: ["**/*.test.ts", "**/*.spec.ts", "**/__tests__/**/*.ts"],
    rules: {
      "@typescript-eslint/no-explicit-any": "off", // acceptable in test mocks
      "no-console": "off",
    },
  },
);

Legacy .eslintrc.cjs (ESLint v8 and earlier):

// .eslintrc.cjs (use in CLAUDE.md if you are on ESLint v8)
module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  parserOptions: {
    project: "./tsconfig.json",
    tsconfigRootDir: __dirname,
  },
  extends: [
    "eslint:recommended",
    "plugin:@typescript-eslint/strict-type-checked",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:import/recommended",
    "plugin:import/typescript",
    "prettier", // must be last; disables formatting rules
  ],
  rules: {
    "react/react-in-jsx-scope": "off",
    "@typescript-eslint/no-explicit-any": "error",
  },
};

The key point for CLAUDE.md: state the format explicitly. eslint.config.js means flat config. .eslintrc.cjs means legacy. Claude will not guess wrong if you make it explicit.

For projects still on ESLint v8 with the compat layer active, add one more line to CLAUDE.md: If generating flat config, use FlatCompat from @eslint/eslintrc to import legacy extends chains. Claude knows about FlatCompat but will skip it without a prompt.

Monorepo setups require one more declaration: whether ESLint config is at the workspace root, per-package, or both. The Claude Code monorepo guide covers the workspace structure patterns that inform this decision, and the ESLint approach follows from whichever config strategy your monorepo uses.

TypeScript-specific rules and the @typescript-eslint setup

TypeScript projects need a different mental model for ESLint than plain JavaScript projects. The @typescript-eslint plugin ships two categories of rules: syntax rules that run without type information (fast, no type checking required), and type-checked rules that require the TypeScript compiler to run during linting (slower, far more powerful). Most teams want the type-checked rules because that is where the useful safety checks live.

Claude's default behaviour without guidance: it enables the recommended config from @typescript-eslint, which uses only the syntax rules. This means @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, and the rest of the type-checked rules are silently absent. Your code passes lint locally but contains the class of bugs those rules would have caught.

Add this to your CLAUDE.md to make the distinction explicit:

## @typescript-eslint setup

### Use strictTypeChecked, not just strict
- extends: tseslint.configs.strictTypeChecked (NOT recommendedTypeChecked)
- This enables: no-unsafe-*, no-explicit-any, strict-boolean-expressions,
  no-floating-promises, no-misused-promises, await-thenable

### parserOptions: type-checking is required
- parseOptions.project must point to the tsconfig.json in scope
- Without project config, type-checked rules silently downgrade to warnings

### no-explicit-any policy: error, not warn
- Fixes: use unknown and narrow with type guard, or use a specific union type
- Acceptable exceptions: test file mocks, third-party module augmentation

### no-floating-promises: error
- Every Promise must be awaited, void-prefixed explicitly, or caught
- Fix: await the call, or prefix with void if intentionally fire-and-forget

### no-misused-promises: error
- Do not pass async functions to event handlers that expect void returns
- Fix: wrap in a sync handler that calls the async function inside

The two rules that generate the most Claude rewrites in practice are no-floating-promises and react-hooks/exhaustive-deps. Both catch real bugs. Both are rules that Claude will disable with a comment rather than fix, unless CLAUDE.md states explicitly that fixes are required. The pattern for each:

no-floating-promises: Claude's mistake is writing someAsyncFn() in a React event handler without awaiting. The fix is either void someAsyncFn() (fire and forget with explicit acknowledgement) or wrapping the handler in a sync function that calls the async one:

// before: floating promise, Claude generates this without guidance
<button onClick={handleDelete}>Delete</button>

async function handleDelete() {
  await deleteItem(id);
}

// after: explicit void or wrapped handler
<button onClick={() => void handleDelete()}>Delete</button>

// or, if you want to handle the error:
<button onClick={() => { handleDelete().catch(console.error); }}>Delete</button>

react-hooks/exhaustive-deps: Claude generates useEffect with a dependency array that matches whatever was in the surrounding context at generation time. If the component later gains new state or props, Claude tends to either leave the array stale or add // eslint-disable-next-line react-hooks/exhaustive-deps. The fix is to keep the rule at warn and treat every warning as a required review, not a suppress-and-ship decision.

For projects using the Claude Code TypeScript guide, the TypeScript compiler settings and the @typescript-eslint rule set reinforce each other. Strict TypeScript narrows the type space; strict ESLint catches the gaps the compiler misses.

The autofix vs review-and-fix decision

ESLint's --fix flag handles a specific category of violations automatically: formatting, import ordering, simple transforms, and fixable syntax issues. It does not touch anything that requires understanding intent. Knowing which category a violation falls into changes what you should ask Claude to do.

Fixable by pnpm lint --fix (ask Claude to run this first, then review):

  • Import ordering (eslint-plugin-import)
  • Unused variable removal (in some configurations)
  • prefer-const upgrades (let x = 1 becomes const x = 1)
  • Quote style and semicolon fixes (if not delegated to Prettier)
  • Simple no-var replacements

Requires code rewriting (Claude should fix in place, not disable):

  • @typescript-eslint/no-explicit-any: needs a proper type, not any
  • @typescript-eslint/no-unsafe-*: needs type narrowing or a type assertion with a comment explaining why
  • no-floating-promises: needs explicit void or a try/catch wrapper
  • react-hooks/exhaustive-deps: needs the dependency array audited and corrected
  • no-console: needs the call replaced with a logger or removed
  • @typescript-eslint/no-unnecessary-condition: the condition is always true or always false, which usually indicates a logic error rather than a lint preference

The workflow Claude should follow in any session where it writes or edits TypeScript/JavaScript:

## Lint workflow (add to CLAUDE.md)

1. After writing or editing any .ts, .tsx, .js, .jsx file, run:
   pnpm lint --fix
2. Review the output for errors (exit code 1 = errors remain)
3. For remaining errors, fix in code. Never add eslint-disable comments without
   explaining why in the same commit message.
4. Run pnpm typecheck after lint passes
5. If both pass, the file is ready for review

This workflow is the same one you would follow manually. Writing it in CLAUDE.md makes it Claude's default instead of something you remind it of per-session.

One legitimate use of eslint-disable: integrating third-party code or calling an API that returns any by design. In those cases, the disable is justified, but it should be scoped as tightly as possible (next-line, not the file) and include a comment explaining the reason:

// Third-party analytics SDK types are not maintained, safe to use here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
analytics.track("event", data as any);

Claude will generate this pattern when the rule is in CLAUDE.md. Without the rule, it generates bare disable comments with no justification, which accumulate until nobody can remember which ones are legitimate.

Prettier integration: keeping formatting out of ESLint

Prettier and ESLint overlap in one area: formatting. ESLint has rules for indentation, quote style, semicolons, trailing commas, and line length. Prettier also controls all of those. Running both without a conflict-prevention layer produces a situation where ESLint wants single quotes, Prettier wants double quotes, and the two tools fight on every save.

The standard solution is eslint-config-prettier, which disables all ESLint formatting rules and delegates that responsibility to Prettier entirely. Claude knows this package but will sometimes scaffold an ESLint config that still includes formatting rules alongside Prettier because it is trying to be helpful.

Your CLAUDE.md should state this explicitly:

## Prettier owns formatting, do not configure formatting in ESLint

### Rules to NEVER add to eslint.config.js
- indent / @typescript-eslint/indent
- quotes / @typescript-eslint/quotes
- semi / @typescript-eslint/semi
- comma-dangle / @typescript-eslint/comma-dangle
- max-len
- object-curly-spacing
- space-before-function-paren

### eslint-config-prettier is installed and applied
- In flat config: import eslintConfigPrettier from "eslint-config-prettier"
  and spread it last in the array: [..., eslintConfigPrettier]
- In legacy config: "prettier" must be the LAST entry in extends array

### .prettierrc settings (do not replicate in ESLint)
{
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2
}

The eslintConfigPrettier spread last in the flat config array is the one step Claude most often omits. Every formatting rule it disables was potentially active before that spread. If Claude adds a new ESLint plugin that ships formatting rules, those rules silently bypass the prettier layer unless eslintConfigPrettier is last. Pinning its position in CLAUDE.md prevents the drift.

For projects using Claude Code with Next.js, the Next.js ESLint config (next/core-web-vitals) adds its own rules on top of this foundation. Those rules are generally non-formatting, so the Prettier separation still applies cleanly. The only thing to verify is that eslintConfigPrettier remains last in the extends chain after next/core-web-vitals.

Common failure modes: what Claude generates without ESLint context

Four patterns appear in Claude-generated code across JS/TS projects when ESLint config is not in CLAUDE.md. Recognising them speeds up review.

Pattern 1: eslint-disable comments on type violations

// Claude generates this when it cannot resolve a type quickly
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function processPayload(data: any) {
  return data.value;
}

// What it should generate instead
function processPayload(data: unknown) {
  if (typeof data !== "object" || data === null || !("value" in data)) {
    throw new Error("Invalid payload shape");
  }
  return (data as { value: string }).value;
}

The disable comment is Claude's way of moving forward when it runs into type complexity. It is almost always wrong. The unknown pattern with a runtime check is the correct replacement, and Claude will generate it consistently when no-explicit-any: error is in CLAUDE.md with a note that the fix requires typing.

Pattern 2: Missing return types on exported functions

@typescript-eslint/explicit-function-return-type or @typescript-eslint/explicit-module-boundary-types catches functions with inferred return types on public APIs. Claude omits return type annotations unless the rule is active in your config, because TypeScript can infer them and the compiler does not require them. If your team's convention is explicit return types on exports, add the rule to must-not-disable and to the CLAUDE.md naming section so Claude annotates every exported function:

// Claude without rule in CLAUDE.md
export function formatDate(date: Date) {
  return date.toISOString().split("T")[0];
}

// Claude with explicit-module-boundary-types in CLAUDE.md
export function formatDate(date: Date): string {
  return date.toISOString().split("T")[0];
}

Pattern 3: Ignoring import ordering entirely

Without eslint-plugin-import noted in CLAUDE.md, Claude groups imports however feels natural at generation time. This is usually three rough groups separated by blank lines, but the groups do not match your configured ordering. The fix is not manually reordering (which Claude will undo next time it touches the file) but running pnpm lint --fix, which the import plugin handles automatically. Adding the workflow step to CLAUDE.md means Claude runs the fix itself.

Pattern 4: Naming convention violations

Claude defaults to common JavaScript naming conventions, but those defaults do not always match your project's ESLint config. If you have naming-convention rules from @typescript-eslint enforcing, say, that interfaces use no I prefix, or that boolean variables start with is/has/should, Claude will violate those rules consistently until they are in CLAUDE.md.

## Naming convention rules (add to CLAUDE.md if enforced by ESLint)
- @typescript-eslint/naming-convention is active
- Interfaces: NO "I" prefix (User, not IUser)
- Boolean variables: must start with is, has, should, or can
- Private class members: no underscore prefix, use private keyword
- Enums: PascalCase names, SCREAMING_SNAKE_CASE members

Claude will match whatever naming pattern is in CLAUDE.md reliably. This is one of the highest-ROI entries to add to the template because naming violations are tedious to fix manually and Claude will generate them in every file without context.

The testing equivalent of this is covered in the Claude Code Jest guide and Claude Code Vitest guide, both of which follow the same pattern of capturing project-specific tool config in CLAUDE.md so Claude stops defaulting to the generic version.

Hooks: running lint automatically on every file edit

Claude Code hooks let you run shell commands at deterministic points in the tool lifecycle, which is a better enforcement mechanism than CLAUDE.md instructions (which are advisory) for lint. A PostToolUse hook that runs ESLint on every file Claude edits means lint violations are caught immediately, before Claude moves to the next file.

In .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "npx eslint --fix \"$CLAUDE_FILE_PATH\" 2>&1 | tail -20"
          }
        ]
      }
    ]
  }
}

This runs eslint --fix on the file Claude just wrote or edited, pipes the output to the last 20 lines (to avoid flooding context with long output), and surfaces any remaining errors. Claude reads the hook output and will attempt to fix remaining violations before proceeding.

The 2>&1 redirect captures ESLint's stderr alongside stdout, which is where the "no config file found" errors appear when the hook runs in a directory without eslint.config.js in scope.

A complementary PreToolUse hook prevents Claude from editing your ESLint config file directly, which protects the config from being overwritten during a session where Claude is also writing application code:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "if [[ \"$CLAUDE_FILE_PATH\" == *eslint.config* ]]; then echo 'ESLint config is protected. Request explicit permission to modify it.'; exit 1; fi"
          }
        ]
      }
    ]
  }
}

For the full hooks system and what else you can wire into Claude Code's tool lifecycle, Claude Code hooks covers the complete API.

Building a project where lint always passes

The ESLint CLAUDE.md section in this guide produces a setup where Claude generates code against your actual rule set (not the generic ESLint default), treats the type-checked @typescript-eslint rules as constraints rather than suggestions, runs lint-then-fix as a workflow step rather than an afterthought, defers all formatting to Prettier without overlap, and uses grep to read your config directly when it needs to verify a rule's current setting.

The underlying principle is the same one that makes any tool integration work with Claude Code. Claude performs at the level of context you give it. A project without ESLint config in CLAUDE.md produces Claude that generates code against its training distribution of "common ESLint configs," which is almost never your config. A project with the template above produces Claude that generates code that passes your lint pipeline on the first try.

The lint-on-edit hook makes this self-correcting: even when Claude guesses wrong, the hook surfaces the violation immediately and Claude fixes it in the same session. The combination of CLAUDE.md context and a PostToolUse hook closes the loop.

For the Claude Code best practices that sit under all of this, the ESLint setup is one instance of a broader pattern: identify the tools that encode your project's standards, document their configuration in CLAUDE.md, and let Claude read from that documentation rather than from its own defaults. ESLint is worth singling out because it covers more project-specific territory than almost any other tool in the JS/TS ecosystem, and because Claude's default fallback (adding disable comments) is the one that causes the most long-term damage to codebases.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir