Linting & Formatting
Two different jobs get conflated under one heading. A formatter decides how code looks — indentation, line breaks, quote style, trailing commas — and it has no opinion about whether the code is correct. A linter finds problems: unused variables, unhandled promises, missing dependencies in a hook, an await inside a loop. Formatting is mechanical and should be entirely automatic; linting requires judgment about which rules earn their keep.
Keeping them separate matters because the failure mode of combining them is well documented. ESLint historically shipped stylistic rules, teams enabled them alongside Prettier, and the two fought — one reformatting what the other had just reformatted. The settled practice is that the formatter owns all of layout and the linter is configured to say nothing about it.
The other thing worth stating: the value of both tools is in removing decisions from code review. A review spent debating brace placement is a review not spent on whether the logic is right, and no human reviewer catches an unused import as reliably as a machine.
TL;DR
- Formatter = how it looks. Linter = whether it's correct. Never let them overlap.
- Disable all stylistic lint rules and let the formatter own layout — this is what
eslint-config-prettieris for. - ESLint flat config (
eslint.config.js) is the current standard;.eslintrcis legacy. - Biome (Rust) and Ruff (Rust) are 10–100× faster single-tool replacements.
- Type-aware linting catches real bugs that syntax-only rules cannot — enable it.
- Run in the editor (fast feedback), on staged files (pre-commit), and in CI (enforcement).
- Adopt incrementally on a large codebase:
--max-warnings, per-directory overrides, and a ratchet. - Format the whole repo in one commit and add it to
.git-blame-ignore-revs.
Quick Example
A modern flat-config ESLint setup with type-aware rules, plus Prettier:
prettier last in the array is load-bearing — flat config applies later entries over earlier ones, so putting it anywhere else leaves stylistic rules enabled and the tools will fight.
Core Concepts
The split
The overlap is where trouble lives. Any lint rule about layout should be off, because the formatter already decided and the two will disagree.
Type-aware linting
This is the largest available upgrade in lint value and the one most often left off, because it's slower.
The cost is real — type-aware linting can be several times slower because it builds the type graph. The tradeoff is usually worth it: no-floating-promises alone catches a bug class that reaches production regularly. Where speed matters, run type-aware rules in CI and the fast subset in the editor.
Biome and Ruff
The Rust-based tools that combine linting and formatting in one binary:
Ruff is close to a straightforward win in Python — it replaces flake8, isort, pyupgrade, pydocstyle, and several others with one tool running orders of magnitude faster, and adoption has been rapid. Biome is excellent and its JavaScript ecosystem coverage is narrower than ESLint's: if you depend on framework-specific plugins (Next.js, Vue, Testing Library, a11y rules with deep JSX awareness), check availability before switching. Its type-aware linting is also less mature than typescript-eslint's.
Where to run it
The editor is where the value is. A lint error surfaced in CI twenty minutes after the commit teaches nothing; one surfaced as you type gets fixed immediately and eventually stops being made.
Adopting on a Large Codebase
The all-at-once approach produces a pull request touching every file and a team that disables the tool. The incremental path:
The ratchet is the mechanism that works: the count can only go down, new code is clean, and legacy improves as it's touched. Requiring zero warnings on day one on a large codebase means either a months-long cleanup project or an ignored tool.
Best Practices
Let the formatter own all layout
Turn off every stylistic lint rule. eslint-config-prettier does this for ESLint; Biome's formatter and linter are already separated. Two tools with opinions about line breaks will fight, and the fight surfaces as a lint error that --fix reintroduces.
Format the whole repo in one dedicated commit
Then add its hash to .git-blame-ignore-revs so git blame skips past it. A formatting commit mixed with logic changes makes the diff unreviewable and the history unusable.
Enable type-aware rules
no-floating-promises, no-misused-promises, and await-thenable catch real production bugs that no syntax-only rule can see. Accept the slower run, and split fast/slow rule sets between editor and CI if the latency bothers people.
Distinguish errors from warnings deliberately
Errors are things that must not merge — likely bugs. Warnings are things to improve — style preferences and maintainability nudges. A configuration where everything is an error trains people to ignore the output or bypass the hook.
Run on staged files in the pre-commit hook
lint-staged keeps the hook fast by only touching what's being committed. A pre-commit hook that lints the whole repo takes 30 seconds and gets bypassed with --no-verify within a week.
Enforce in CI, with --max-warnings=0 on trunk
Local hooks can be skipped; CI cannot. The hook is a convenience that catches things early, and CI is the actual gate.
Require a reason for every disable comment
The eslint-comments/require-description rule enforces this. An unexplained disable is indistinguishable from someone silencing a real finding.
Keep the rule set small and justified
Every rule has a false-positive cost paid by every engineer on every violation. Start from recommended, add rules that catch bugs you've actually shipped, and remove rules people routinely disable — a rule that's always suppressed is a rule that's wrong for your codebase.
Common Mistakes
Linter and formatter fighting
A repo-wide format mixed into a feature PR
Everything as an error
Only running in CI
Blanket file-level disables
Skipping type-aware rules because they're slow
FAQ
Biome or ESLint + Prettier?
Biome if you value speed and a single tool, and your project doesn't depend on ESLint plugins that Biome hasn't implemented — check your specific framework and a11y rules first. ESLint plus Prettier if you need the plugin ecosystem, deep type-aware linting, or custom rules. Many teams are moving formatting to Biome while keeping ESLint for type-aware rules, which works and means running two tools.
Should we use Ruff for Python?
Almost certainly yes. It replaces flake8, isort, pyupgrade, pydocstyle, and much of pylint with one tool that runs 10–100× faster, and its rule coverage is now extensive. ruff format is a near-drop-in Black replacement. The migration is usually a day, and the speed difference changes how often people run it — which is the actual benefit.
How do we stop arguments about formatting?
Adopt the tool's defaults and stop configuring. Prettier's defaults, Black's defaults, gofmt — the point of an opinionated formatter is that the decision is made and nobody's preference wins. Every configuration option you add is a decision someone can relitigate, which is exactly what you were trying to eliminate.
Do we need both a pre-commit hook and CI?
They do different jobs. The hook gives fast feedback and keeps unformatted code out of history; it can be bypassed, so it isn't a gate. CI is the gate and gives feedback too late to be pleasant. Both, plus editor integration, is the standard setup — and the editor is where the actual behavior change happens.
What about custom lint rules?
Worth writing for genuinely project-specific invariants: "don't import from internal/ outside the module," "every API handler must call requireAuth," "no direct process.env access outside the config module." ESLint's rule API is approachable, and a few well-chosen custom rules encode architectural decisions that would otherwise rely on review vigilance. Alternatively, Semgrep expresses many of these patterns without writing a plugin.
Will AI-generated code pass our linter?
Often not on the first attempt, and that's useful — the linter is a cheap, automatic check on generated code, and running it (plus the formatter) as part of any generation workflow catches unused imports, missing awaits, and inconsistent patterns immediately. The characteristic issues in generated code (overly broad types, unhandled promises, inconsistent import style) are exactly what a good rule set flags.
Related Topics
- Code Quality — The broader practice these tools support
- Code Review — What automation removes from review
- Turborepo — Caching lint tasks in a monorepo
- CI/CD — Where enforcement lives
- TypeScript — Type-aware linting depends on it
- Python — Ruff's ecosystem
- IDEs & Editors — Editor integration, where feedback matters most
- Documentation Style Guides — The same argument applied to prose
- Git — Hooks and
.git-blame-ignore-revs