Pragmatic ESLint: My recommendations
Pragmatic ESLint: Recommendations
ESLint can feel like it's fighting you. Red squiggles everywhere. Auto-fix battles with Prettier. Rules that seem arbitrary. After many years I have this philosophy:
- auto fix when possible
- errors on things that have proven to cause bugs, security issues, or slow developers down
- errors to help debug/test code leaking into prod
Here's the configuration I've converged on, and will update it as time goes on. I try to keep it minimal unless a rule matches one of the above points.
The Philosophy
Before we dive into the configuration, let's establish some principles:
-
Errors should prevent bugs - If something is marked as an error, it should represent a real problem that could break your application or confuse developers.
-
Style is Prettier's job - ESLint shouldn't fight with Prettier over formatting. One tool for style, one for logic.
-
Make migration practical - Perfect is the enemy of shipped. Use grandfathering and incremental adoption to get your team on board.
-
Grandfather old code, enforce new standards - When introducing strict rules, it's often impractical to fix everything at once. Allow existing violations while preventing new ones.
The Configuration
This uses ESLint 9's new flat config format with the latest typescript-eslint recommendations:
// eslint.config.mjs // @ts-check import eslint from "@eslint/js" import { defineConfig } from "eslint/config" import tseslint from "typescript-eslint" import reactHooks from "eslint-plugin-react-hooks" import noOnlyTests from "eslint-plugin-no-only-tests" export default defineConfig( eslint.configs.recommended, tseslint.configs.recommended, tseslint.configs.stylistic, { plugins: { "react-hooks": reactHooks, "no-only-tests": noOnlyTests, }, rules: { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", "no-console": [ "error", { allow: ["info", "warn", "error", "dir", "table"] }, ], "no-warning-comments": [ "error", { terms: ["fixme"], location: "anywhere", }, ], "@typescript-eslint/no-unused-vars": [ "error", { args: "all", argsIgnorePattern: "^_", caughtErrors: "all", caughtErrorsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_", varsIgnorePattern: "^_", ignoreRestSiblings: true, }, ], "@typescript-eslint/no-explicit-any": "off", "no-only-tests/no-only-tests": [ "error", { focus: ["only", "skip"], }, ], }, }, { ignores: ["dist/**", "node_modules/**", "*.config.js", "jest.config.js"], }, )
Rule-by-Rule Rationale
React Hooks: Error, Always
"react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error",
Why errors? Violating the Rules of Hooks causes runtime bugs that are confusing to debug. Missing dependencies in useEffect leads to stale closures that silently use old values.
What about legacy code? Use the grandfathering script (see appendix below) to add eslint-disable-next-line comments to existing violations. This lets you enforce the rule going forward while giving you time to fix old code.
Why not warnings? Warnings get ignored. If something matters enough to catch, make it an error that blocks your CI/CD pipeline.
Explicit Any: Off (But Use Sparingly)
"@typescript-eslint/no-explicit-any": "off",
Why off? In real codebases, there are legitimate uses for any - especially when dealing with third-party libraries without types, or truly dynamic data structures. The strict prohibition creates friction without much value.
Best practice: Even though it's off, avoid any where possible. Consider using:
unknownwhen you don't know the type yet- Generic type parameters for flexible but type-safe code
- Type assertions with care:
as SomeType
Future consideration: You could enable this as a warning and use eslint-disable-next-line any comments for legitimate cases. This makes any usage explicit and searchable.
Leaving implicit any as an error forces you to type that any and feel guilty about it. And you should feel guilty (especially those llms which like to cheat this).
Block FIXME Comments
"no-warning-comments": [ "error", { terms: ["fixme"], location: "anywhere", }, ],
Why? FIXME comments are incredibly useful during development - they mark technical debt that you must address before committing. Making this an error ensures you can't forget about them.
Workflow:
- During development: Use
// FIXME: handle error casefreely - Before committing: ESLint blocks the commit
- You either fix it or convert it to
// TODO: ...if it's truly future work
Why not TODO? TODO comments document intended future improvements. FIXME comments document current problems that shouldn't ship. Use them sparingly and have a plan to do them or remove them from the code. Adding dates can help and there are lint rules that even error when the date is too old.
Console.log: Development Tool Only
"no-console": [ "error", { allow: ["info", "warn", "error", "dir", "table"] }, ],
Why block console.log? It's a debugging tool. You added it to figure something out, but it shouldn't stay in production code.
What about logging? Use the allowed methods for intentional logging:
console.info()- Informational messagesconsole.warn()- Warnings that users should seeconsole.error()- Error messagesconsole.dir()/console.table()- Rich debugging that's intentionally left in
Frontend logging: For production frontend logging, use a proper logger (logger.debug(), logger.info(), etc.) that can be configured per environment.
Test .skip and .only: Prevent Accidents
"no-only-tests/no-only-tests": [ "error", { focus: ["only", "skip"], }, ],
Why? It's incredibly easy to use .only while debugging a test, then forget to remove it. This breaks the entire test suite - but only in CI, after you've pushed.
Similarly, .skip can accumulate forgotten tests that no longer run. They should only be used for flaky tests that we should come back to fixing later. If the test is no longer useful just delete it.
Why the plugin? The old approach using no-restricted-syntax highlighted the entire test block in red. The eslint-plugin-no-only-tests plugin only highlights the .only or .skip portion, making it much easier to spot and fix.
Legitimate uses? Use an inline disable comment:
// eslint-disable-next-line no-only-tests/no-only-tests it.only('should debug this specific case', () => { // ... })
This makes the exception explicit and searchable.
Unused Variables: Error, Not Warning
// Using default from @typescript-eslint/recommended "@typescript-eslint/no-unused-vars": "error",
Why error? I've experimented with making this a warning in the past. People ignored warnings and things that aren't in PR checks. Unused variables pollute the codebase and create confusion:
- Are they used somewhere I can't see?
- Is this old code that should be removed?
- Is this a typo? Did they mean to use it?
- A tiny bit less context for those llms 😄
Making it an error forces the cleanup. The cost is minimal - just remove the variable or prefix it with underscore if it's intentionally unused:
const [_data, setData] = useState() // Intentionally unused
No Stylistic Linting
What's missing? Notice we're not using @typescript-eslint/stylistic rules for things like:
- Semicolons
- Quotes (single vs double)
- Trailing commas
- Indentation
- Line length
Why? This is Prettier's job. Running both ESLint stylistic rules and Prettier creates conflict:
- ESLint auto-fix changes formatting
- Prettier auto-format changes it back
- ESLint complains again
- Infinite loop of fixes
Single source of truth: Prettier handles all formatting. ESLint handles logic and bug prevention. No overlap, no conflicts.
Prettier: The One True Formatter
While ESLint handles logic, Prettier handles style. The beauty of Prettier is captured in the Go community's saying about gofmt:
"Gofmt is no one's favorite, yet gofmt is everyone's favorite."
Prettier embodies this philosophy. You might not agree with every decision, but having one decision that everyone follows is more valuable than having the "perfect" style.
The Configuration
Here's my minimal Prettier config:
{ "semi": false, }
That's it. Everything else uses Prettier's defaults.
Why semi: false?
You'll often hear these arguments for why semicolons are "best practice" in JavaScript:
Clarity and Readability - Explicit semicolons clearly delineate the end of a statement, making the code easier to read and understand.
Avoiding Ambiguity and Bugs - ASI can sometimes lead to unexpected behavior or subtle bugs, particularly when line breaks are placed in unusual locations. Explicit semicolons eliminate this ambiguity.
Consistency with C-style Languages - If you work with other C-style languages (like Java, C++, or C#) that require semicolons, using them in JavaScript maintains consistency.
Team Standards and Linters - Many professional development environments and teams adopt coding style guides that mandate the use of semicolons to ensure code quality and consistency.
These sound reasonable, but I've found the opposite to be true in practice. I picked up the no-semicolon approach from StandardJS, which provides compelling arguments along with links to these three deep-dives:
- An Open Letter to JavaScript Leaders Regarding Semicolons by Isaac Z. Schlueter
- JavaScript Semicolon Insertion: Everything you need to know by inimino
- Are Semicolons Necessary in JavaScript? video discussion
My Counterpoints
"Clarity and readability" - Actually, semicolons add visual noise for a character that's not needed. Removing them makes code cleaner and faster to parse.
"Avoiding ambiguity and bugs" - This hasn't caused a bug for me or my teams in the last 10 years of not using semicolons. Automatic semicolon insertion handles edge cases, and unreachable code on returns is quite obvious. When semicolons are actually needed, Prettier automatically inserts them, so we can see explicitly where they've been injected purposefully.
"Consistency with C-style languages" - Go, which I consider one of the best designed modern languages, also forgoes semicolons (and Python, though do we even count that?). To be fair, Rust has chosen semicolons, but Go is a modern C-style language that explicitly chose to remove them from their standard formatter.
It's worth noting that Go's designers — Robert Griesemer, Rob Pike, and Ken Thompson — had extensive experience with C and systems programming. Ken Thompson co-created the C programming language and Unix, and Rob Pike worked at Bell Labs on Plan 9. Their deep understanding of C's strengths and weaknesses heavily influenced Go's design, including the decision to drop mandatory semicolons.
As explained in Automatic Semicolon Insertion in Go:
What is the reason for language designers to even start working on getting rid of tokens like semicolons? The answer is quite simple. It's all about readability. The less artifacts code has, the easier it's to work with. It's important since once written piece of code will be probably read many times by different people.
"Team standards and linters" - Prettier is exactly what this is for, so the style is what's in the repo.
My Two Reasons for No Semicolons
1. Code clarity - Knowing where semicolons are needed and removing visual clutter makes code appear cleaner and easier/faster to parse.
2. Moving code around is easier - Without trailing semicolons, refactoring becomes more fluid. Think about operations like .map().filter() where you're constantly adding, removing, or reordering chains.
Consider this common scenario:
// With semicolons - awkward to refactor const users = getUsers(); const activeUsers = users.filter(u => u.active); // Want to chain another operation? You need to: // 1. Remove the semicolon from line 1 // 2. Add your new operation const users = getUsers() .filter(u => u.active);
// Without semicolons - natural refactoring const users = getUsers() const activeUsers = users.filter(u => u.active) // Want to chain? Just move the code const users = getUsers() .filter(u => u.active)
When copying and pasting code, adding filters, or splitting chains, semicolons create friction. Remove them, and code movement becomes fluid.
JavaScript's Automatic Semicolon Insertion (ASI) handles this automatically, and TypeScript catches the edge cases where ASI might cause problems.
Note: I'll change code to use semicolons just to speed things up when needed, but more for saving time from bikeshedding this one and less because I think they're necessary.
Biome: A Modern Alternative
Biome is a newer formatter that's gaining traction. It's:
- Faster than Prettier (written in Rust)
- Opinionated with even fewer configuration options
- Combined linter and formatter in one tool
Biome's philosophy: "A formatter should have as few options as possible." They provide even less configuration than Prettier, which is a good thing - fewer decisions mean less bikeshedding.
If you're starting a new project and want bleeding-edge tooling, Biome is worth considering. For existing projects, Prettier's ecosystem maturity is hard to beat.
Key Takeaway
Pick one formatter (Prettier or Biome), configure it minimally, and enforce it everywhere. The specific choices matter less than everyone using the same tool.
Migration Strategy: Grandfathering Exhaustive Deps
The hardest rule to adopt is react-hooks/exhaustive-deps. In a large codebase, you might have hundreds of violations. Fixing them all at once is impractical and risky.
Here's the strategy:
- Add the rule as an error
- Run a script to add
eslint-disable-next-lineto all existing violations - Fix them incrementally over time
- New code must follow the rule
The Script
Save this as scripts/eslint-one-time-allow.ts:
// This script reads all the .ts and .tsx files in the src directory and disables // all the eslint errors of a specific rule (default: react-hooks/exhaustive-deps). // This is intended to be used to grandfather existing problems so that future // problems can be fixed. It may also be useful to disable other future eslint // errors that need to be allowed until such a time that they can be fixed. // usage: tsx scripts/eslint-mass-allow.ts --add-allow-comments // usage: tsx scripts/eslint-mass-allow.ts --rule @typescript-eslint/no-unused-vars --add-allow-comments import { Command } from "@commander-js/extra-typings" import * as fs from "fs" import { ESLint } from "eslint" const program = new Command() program .name("eslint-mass-allow") .description("Mass fix ESLint errors by adding disable comments") .option("--add-allow-comments", "Actually add the allow comments (dry run by default)") .option( "--rule <rule>", "ESLint rule to allow", "react-hooks/exhaustive-deps" ) .parse() const options = program.opts() as { addAllowComments?: boolean; rule: string } async function main() { const eslint = new ESLint() const results = await eslint.lintFiles(["src/**/*.ts", "src/**/*.tsx"]) let totalErrors = 0 let filesToFix = 0 for (const result of results) { if (result.errorCount > 0) { const targetErrors = result.messages.filter( (message: any) => message.ruleId === options.rule ) if (targetErrors.length > 0) { filesToFix++ totalErrors += targetErrors.length console.log( `${result.filePath}: ${targetErrors.length} ${options.rule} errors` ) if (options.addAllowComments) { const fileContent = fs.readFileSync(result.filePath, "utf-8") .split("\n") let offset = 0 // Track the number of lines added for (const message of targetErrors) { const lineIndex = message.line - 1 + offset fileContent.splice( lineIndex, 0, `// eslint-disable-next-line ${message.ruleId}` ) offset++ } fs.writeFileSync(result.filePath, fileContent.join("\n"), "utf-8") } } } } console.log(`\nSummary:`) console.log(`Files with ${options.rule} errors: ${filesToFix}`) console.log(`Total errors: ${totalErrors}`) if (!options.addAllowComments) { console.log( `\nThis was a dry run. Use --add-allow-comments to apply the changes.` ) } else { console.log(`\nAdded allow comments for ${totalErrors} errors in ${filesToFix} files.`) } } main() .then(() => { console.info("Done") }) .catch((error) => { console.error("Error:", error) })
Using the Script
# Dry run - see what would be allowed npx tsx scripts/eslint-mass-allow.ts # Actually add the allow comments npx tsx scripts/eslint-mass-allow.ts --add-allow-comments # Allow a different rule (e.g., unused variables) npx tsx scripts/eslint-mass-allow.ts --rule @typescript-eslint/no-unused-vars --add-allow-comments
This approach lets you:
- Enable strict rules immediately
- Not break your existing codebase
- Fix violations incrementally
- Prevent new violations from being added
The Modern Config Format
You might have noticed we're using defineConfig() instead of the older tseslint.config():
import { defineConfig } from "eslint/config" export default defineConfig( eslint.configs.recommended, tseslint.configs.recommended, // No spread operators needed! )
This is the new recommended approach from typescript-eslint. Benefits:
- Cleaner syntax (no spread operators)
- Better type inference
- Aligns with ESLint 9's flat config philosophy
If you're still using the old format:
export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, // Spreading required )
Update to the new format. It's a simple find-and-replace.
Practical Tips for Teams
Rolling It Out
- Start with recommended configs - Don't customize too much initially
- Add rules incrementally - One rule at a time, with team discussion
- Use the grandfathering script - For rules with many violations
- Document your reasoning - Keep a
LINTING.mdfile explaining each custom rule
When to Use eslint-disable
Inline disable comments are fine when:
- You're interfacing with untyped third-party code
- The rule doesn't apply to your specific case
- You're intentionally using a pattern for a good reason
Just make it explicit:
// We need any here because the API returns dynamic structure // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: any = await fetchDynamicData()
CI/CD Integration
Run linting before tests:
- name: Lint run: npm run lint - name: Type Check run: npm run typecheck - name: Test run: npm test
Fast feedback on code quality issues.
Future Considerations
Type-Aware Linting
The configuration above uses tseslint.configs.recommended. For deeper type checking, consider:
export default defineConfig( eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { languageOptions: { parserOptions: { project: './tsconfig.json', }, }, }, )
This enables rules like:
@typescript-eslint/no-floating-promises- Catch unhandled promises@typescript-eslint/no-misused-promises- Type-check async/await usage@typescript-eslint/await-thenable- Only await actual promises
Trade-off: Significantly slower linting (requires full TypeScript compilation).
Strict Mode
For new projects with no legacy code:
export default defineConfig( eslint.configs.recommended, tseslint.configs.strict, // Instead of recommended tseslint.configs.stylistic, // ... your rules )
The strict preset includes more opinionated rules that catch subtle bugs.
Key Takeaways
- ESLint catches bugs, Prettier handles style - Don't overlap
- Errors block CI, warnings get ignored - Use errors for things that matter
- Grandfather legacy code - Use the mass-allow script for incremental adoption
- Document your reasoning - Help your team understand why each rule exists
- The new flat config is better - Use
defineConfig()with ESLint 9
The Result
This configuration has been battle-tested across multiple teams and projects. It:
- Catches real bugs before they hit production
- Doesn't get in the way during development
- Plays nicely with other tools
- Can be adopted incrementally
Most importantly, developers don't fight it. When ESLint complains, there's usually a good reason.
Have a different approach to ESLint configuration? I'd love to hear what works for your team!