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

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

References