---
title: "17 — Linters and Formatters — Two Jobs I Used to Mash Into One"
uid: linters-and-formatters
tags: ["tooling", "eslint", "prettier", "biome", "linting", "roadmap:frontend", "code-quality"]
excerpt: "A formatter is mechanical — apply the rules, don't argue. A linter is judgmental — flag suspect patterns for a human. Biome fuses both into one fast Rust tool."
date: 2026-08-12T18:35:08+0000
source: https://www.aveshina.my.id/en/blog/linters-and-formatters
---

I pointed one tool at everything for years, which is how linting and formatting stayed fused in my head. The distinction that went from fuzzy to load-bearing: **a formatter is mechanical, a linter is judgmental.** A formatter decides *how the code looks* and applies it without debate; a linter decides *what's likely wrong* and flags it for a human [1][3]. Once I could see those as two separate questions, picking tools — and deciding when in my workflow to run them — stopped being guesswork.

The reason they have to be separate is that they answer different questions on the same source file:

```figure
<svg viewBox="0 0 740 280" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="One source file inspected by two tools with different jobs. A box on the left labelled source file sends an arrow up-right to a formatter, which rewrites whitespace, quotes, line width and returns formatted code. It sends a second arrow down-right to a linter, which does not rewrite but flags suspect patterns (unused variable, missing dependency) as warnings. Formatter is mechanical, linter is judgmental.">
  <defs>
    <marker id="lfarrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <!-- source file -->
    <rect x="30" y="100" width="160" height="80" rx="10" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="110" y="132" font-size="13" font-weight="700" fill="#1e1b4b" text-anchor="middle">source file</text>
    <text x="110" y="150" font-size="10.5" fill="#475569" text-anchor="middle">the bytes you wrote</text>
    <text x="110" y="166" font-size="10.5" fill="#475569" text-anchor="middle">same input to both</text>

    <!-- formatter -->
    <rect x="470" y="40" width="240" height="80" rx="10" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="590" y="68" font-size="13" font-weight="700" fill="#422006" text-anchor="middle">Formatter (Prettier)</text>
    <text x="590" y="88" font-size="10.5" fill="#475569" text-anchor="middle">rewrites HOW it looks</text>
    <text x="590" y="104" font-size="10.5" fill="#475569" text-anchor="middle">indent · quotes · line width</text>

    <!-- linter -->
    <rect x="470" y="160" width="240" height="80" rx="10" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="590" y="188" font-size="13" font-weight="700" fill="#500724" text-anchor="middle">Linter (ESLint)</text>
    <text x="590" y="208" font-size="10.5" fill="#475569" text-anchor="middle">flags WHAT's likely wrong</text>
    <text x="590" y="224" font-size="10.5" fill="#475569" text-anchor="middle">bugs · smells · unused vars</text>

    <!-- arrows from source to each -->
    <path d="M190,128 C320,128 380,80 468,80" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#lfarrow)"/>
    <path d="M190,152 C320,152 380,200 468,200" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#lfarrow)"/>

    <!-- verbs -->
    <text x="335" y="96" font-size="10" fill="#64748b" text-anchor="middle" font-style="italic">rewrite</text>
    <text x="335" y="188" font-size="10" fill="#64748b" text-anchor="middle" font-style="italic">warn</text>
  </g>
</svg>
```

The formatter touches the bytes and hands them back neater; the linter leaves the bytes alone and prints a list of suspects. One edits, one reports. That's the whole split.

## Why the jobs were split in the first place

For a long time I tried to make one tool do both, and it fought me. The reason is that the two jobs have incompatible defaults:

- **Formatting is non-negotiable.** There is exactly one correct way to lay out a line under a given config. Arguing about it in code review is waste. So the formatter is *opinionated* and *automatic* — give it rules, and it rewrites without asking [2].
- **Linting is negotiable.** A "unused variable" might be a bug, or it might be intentional, or it might be a false positive from a macro. So the linter is *configurable* and *advisory* — it flags, and a human decides whether to fix, ignore, or disable the line [1].

When I tried to push formatting into ESLint, it either auto-fixed things I didn't want changed or nagged about every indentation discrepancy without fixing it. When I tried to push real bug-catching into Prettier, I hit the wall that Prettier explicitly refuses to do anything but format — its own docs say it "takes whatever code you throw at it and spits out the same code, but consistently formatted," nothing more [2]. Each tool is optimized for its own question; pretending one can do both is why teams ended up running both side by side.

## The Prettier + ESLint combo

Before Biome, the standard setup was exactly those two tools, and the trick was getting them not to collide. Prettier owns whitespace, quotes, semicolons, and line width; ESLint owns everything that requires understanding the code — unused imports, hooks dependency arrays, == vs ===, unreachable branches [3]. The collision is that ESLint also ships style rules (indentation, quotes) that, if left on, fight Prettier's output. The fix was always the same: run eslint-config-prettier to turn off every ESLint rule that Prettier already handles, so the two tools each get a non-overlapping lane [3]. Once that's wired, the combo is genuinely clean — format with Prettier, lint with ESLint, no rule ever fires twice.

## Why Biome — one tool, fast

Biome is the thing that finally made the "one tool" idea work, and the way it does it is by *fusing* the two jobs into one Rust binary rather than bolting them together [4]. (Rust is a fast, low-level compiled language — the reason Biome can be this quick.) Two consequences fell out of that decision that mattered to me:

- **One tool to configure.** No eslint-config-prettier, no two config files, no reconciling overlapping rules. Biome's formatter and linter share a single biome.json and a single parse of the file, so they can't disagree with each other [4].
- **It's fast enough to run constantly.** Because the formatter and linter operate on one **AST** — an "abstract syntax tree," the code broken down into a structured tree the way a sentence can be diagrammed into subject and verb, which both tools read from a single parse — in one pass in Rust, the same invocation that formats also lints in milliseconds. That changes *where* you run it — it stops being a chore you batch up and becomes something you can afford on every save and every commit.

The tradeoff I'm honest about: Biome's rule coverage is narrower than ESLint's plugin ecosystem. ESLint has a decade of framework-specific plugins (Next.js, React Hooks, TypeScript, import); Biome covers the common cases well but, on the day I wrote this, doesn't match that long tail [4]. For a greenfield side project I reach for Biome; for a large existing app with deep ESLint plugin usage, the combo is still the safer call.

## Where to run them — save, pre-commit, CI

The tools are only half of it. The other half is *when* they run, and the habit that finally stopped me from shipping style nits and silly bugs was running them at three escalating gates, each cheaper than the next:

- **On save, in the editor.** Format-on-save with the formatter, and "fix on save" for the linter's safe auto-fixes. This is the cheapest feedback loop — I see the change instantly, before the file even hits git. It catches 80% of the noise for free [2].
- **Pre-commit, via a hook.** A lint-staged style hook runs both tools only on the files staged in the commit, so a badly-formatted file can't enter history and the hook stays fast because it skips the rest of the repo. This is the safety net for when save-on-format is off or someone edited outside the editor.
- **In CI, on every push.** CI — short for continuous integration — is the automated job that runs on a server every time code is pushed. The same lint + format --check command runs over the whole repo. This is the last gate — it catches anything that slipped the first two, and it's what keeps main green regardless of how any individual dev's editor is set up. CI is the only gate that's *enforced*; save and pre-commit are conveniences that can be bypassed.

The progression matters: save is instant, pre-commit is per-commit, CI is the backstop. If something only exists in CI, developers hit the delay and stop trusting it. If it only exists on save, one misconfigured editor lets bad code in. Running all three is what makes the model actually hold.

## How I use this

The habit these notes left me with is a one-line question whenever I see a tool warning: *is this a formatting problem or a judgment problem?* If it's the former — whitespace, quotes, line width — it belongs to the formatter and I shouldn't be reading it in a review. If it's the latter — a likely bug, a smell, a suspect pattern — it belongs to the linter and it's worth my attention. Splitting the two jobs cleanly is the whole reason neither one feels like noise anymore.

## References

[1] ESLint, "What is ESLint?," eslint.org, 2024. [Online]. Available: [https://eslint.org/docs/latest/use/core-concepts](https://eslint.org/docs/latest/use/core-concepts)

[2] Prettier, "Why Prettier?," prettier.io, 2024. [Online]. Available: [https://prettier.io/docs/en/why-prettier.html](https://prettier.io/docs/en/why-prettier.html)

[3] Prettier, "Integrating with ESLint," prettier.io, 2024. [Online]. Available: [https://prettier.io/docs/en/integrating-with-linters](https://prettier.io/docs/en/integrating-with-linters)

[4] Biome, "Biome — the fast all-in-one toolchain," biomejs.dev, 2024. [Online]. Available: [https://biomejs.dev/internals/language-support/](https://biomejs.dev/internals/language-support/)

```quiz
Q: A formatter and a linter both read your source file. What's the core difference in what they DO with it?
- A formatter rewrites how it looks; a linter flags what's likely wrong without rewriting
- Both rewrite the code the same way, a linter is just slower
- A linter rewrites formatting; a formatter flags bugs
correct: 0
explain: The formatter edits the bytes (indent, quotes, line width) and returns neater code. The linter leaves the bytes alone and prints a list of suspect patterns for a human to review. One edits, one reports.

Q: You run Prettier and ESLint together and ESLint keeps flagging indentation Prettier already fixed. The right fix is…
- disable every ESLint style rule that Prettier already handles (e.g. eslint-config-prettier)
- remove Prettier and let ESLint handle all formatting
- turn off ESLint entirely
correct: 0
explain: Prettier owns formatting; ESLint owns judgment. eslint-config-prettier turns off the ESLint rules that overlap with Prettier so the two tools each get a non-overlapping lane and never fire twice.

Q: Why is Biome able to fuse formatting and linting into one tool without the usual collision?
- it runs both off a single shared AST in one pass, in compiled Rust, so the formatter and linter can't disagree
- it disables linting entirely and only formats
- it forks Prettier and ESLint and runs them in parallel
correct: 0
explain: Biome's formatter and linter share one parse and one config, so there's no overlapping-rules problem to reconcile — a consequence of building both jobs into one Rust binary instead of bolting two tools together.

Q: Which gate is the only ENFORCED one — the one a misconfigured editor can't bypass?
- format-on-save
- the pre-commit hook
- CI, running lint + format --check on every push
correct: 2
explain: Save and pre-commit are conveniences that can be skipped or misconfigured. CI runs over the whole repo on every push regardless of any individual's editor, which makes it the backstop that keeps main green.

Q: "Unused variable" is flagged by your linter. Is that a formatting problem or a judgment problem?
- Formatting — it's about how the line looks
- Judgment — it might be a bug, an intentional placeholder, or a false positive, so a human decides
correct: 1
explain: An unused variable requires understanding the code's intent, which is exactly the linter's lane. Formatting handles mechanical concerns (whitespace, quotes); "is this variable supposed to be here" is judgmental, not mechanical.
```
