Code Quality: ESLint, Prettier & Biome

Two engineers open a pull request. One indents with two spaces, the other with four. One writes if (x) on its own line, the other crams the body onto the same line. Neither is wrong. Both spend forty minutes arguing about it in review comments while the actual logic — a race condition three lines down — sails through untouched.

That is the problem this article solves. Not “which style is correct” (nobody cares), but “how do we stop humans from spending attention on style at all, so the attention lands on logic.” The answer is a small pile of tools that read your code before it runs, rewrite the trivial stuff automatically, and flag the dangerous stuff loudly. Get them wired up once and consistency becomes something the machine guarantees rather than something people police.

This builds on Modules, npm & Semantic Versioning and Build Tools — these tools live in the same node_modules, run from the same package.json scripts, and slot into the same pipeline.

Two jobs that keep getting confused

For years people asked “ESLint or Prettier?” as if picking one. That framing is wrong, because they do different jobs. Untangling them is the single most useful idea here.

A formatter cares only about how code looks: indentation, quote style, where line breaks fall, trailing commas, semicolons. It never changes what the program does. Feed it messy-but-valid code and it hands back tidy code with identical behavior. It has no opinion about whether your code is good — only about whether it is consistent.

A linter cares about what code means: unused variables, a == where you meant ===, an await inside a loop, a promise you forgot to return, a React hook called conditionally. Some of what a linter finds are real bugs; some are patterns your team has decided to forbid. A linter can rewrite code too, but only for fixes it’s confident about — it will not touch anything ambiguous.

source.jslet x = 1if(x==1){foo( )}let unused=2 bar( x )FORMATTERrewrites layout · behavior unchangedlet x = 1;if (x == 1) { foo();}LINTERflags meaning · ignores layout⚠ x == 1 → use ===⚠ ‘unused’ never read✕ ‘bar’ is not definedno auto-rewrite here
The same messy file, two different jobs. The formatter rewrites layout and leaves behavior untouched. The linter ignores layout and flags the things that could actually break.

Because they own different territory, the modern setup runs both: Prettier (or Biome’s formatter) owns style, ESLint (or Biome’s linter) owns correctness, and you configure them so they never fight over the same characters. More on that boundary later.

ESLint and the flat config

ESLint is the long-standing linter for JavaScript and TypeScript. As of July 2026 the current major is ESLint v10; v9 (which shipped April 2024) reaches end-of-life on 2026-08-06, so any project you touch now should already be on the modern configuration format — flat config — which v9 made the default and v10 is built around. If you find an old .eslintrc.json with an extends array of strings and an env block, that’s the legacy format; it still runs under a compatibility shim but you should migrate.

Flat config lives in a single file, eslint.config.js, and it is just JavaScript that exports an array of configuration objects. ESLint reads the array top to bottom and merges the objects that apply to a given file. That’s the entire mental model: a list, evaluated in order, later entries winning.

// eslint.config.js
import { defineConfig } from "eslint/config";
import js from "@eslint/js";

export default defineConfig([
  js.configs.recommended,
  {
    files: ["**/*.js"],
    rules: {
      "no-console": "warn",
      eqeqeq: "error",
      "prefer-const": "error",
    },
  },
]);

defineConfig is imported from the eslint/config entry point. It doesn’t change how ESLint behaves — it wraps your array so editors give you autocomplete on the config shape and so ESLint throws a clear error if you mistype a key. You can also just export default [ ... ] a plain array; the helper is convenience, not magic.

Each object in the array can carry a handful of keys:

  • files / ignores — glob patterns deciding which files this object applies to. Omit files and the object applies globally.
  • rules — the rules and their severities (below).
  • plugins — third-party rule packs, registered under a namespace.
  • languageOptionsecmaVersion, sourceType, globals, and the parser (e.g. the TypeScript parser).
  • extends — pull in and compose other config arrays (added in 2025; before that you spread them manually).
  • linterOptions — knobs like reportUnusedDisableDirectives.
export default [ ]js.configs.recommendedcore “likely bug” rulestseslint.configs…TypeScript rules + parserpluginReact.configs…framework plugin{ rules: { … } }your overrides — win lastmerge forButton.tsxobjects whose files: matcheffectiveruleset
Flat config is an ordered array. ESLint concatenates the entries that match a file and merges them left to right, so a later object can override an earlier one for the same rule.

Rules and severities

A rule maps a name to a severity. There are three levels, and each has a string and a numeric form:

  • "off" / 0 — rule disabled.
  • "warn" / 1 — reported, but does not change the exit code. CI stays green.
  • "error" / 2 — reported and fails the run with a non-zero exit code. CI goes red.

That exit-code distinction matters: warn is a nudge you’ll see in your editor; error is a gate that blocks a merge. Reserve error for things you genuinely want to stop a deploy.

Rules that take options use an array — first element is the severity, the rest are configuration:

rules: {
  // just a severity
  "no-console": "warn",

  // severity + options
  quotes: ["error", "single", { avoidEscape: true }],
  "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
}

Plugins and shareable configs

The core rules ship with ESLint. Everything else — React, Vue, accessibility, import ordering, security — comes from plugins: npm packages that add rules under a namespace, usually alongside a ready-made “recommended” config you drop into your array. In flat config a plugin is registered by importing it and putting it in the plugins object, then referencing its rules with the namespace/rule-name syntax.

import js from "@eslint/js";
import pluginImport from "eslint-plugin-import";
import { defineConfig } from "eslint/config";

export default defineConfig([
  js.configs.recommended,
  {
    plugins: { import: pluginImport },
    rules: {
      "import/no-cycle": "error",
      "import/order": ["warn", { "newlines-between": "always" }],
    },
  },
]);

Typed linting for TypeScript

Here’s where ESLint does something a formatter never could. With typescript-eslint wired up, some rules get access to the type checker — the same type information your editor uses — so they can catch bugs that need to understand types, not just syntax. A floating promise, a value that’s any leaking through, comparing a string to a number: these require knowing types.

import tseslint from "typescript-eslint";

export default tseslint.config(
  tseslint.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
);

projectService: true (the stable name since typescript-eslint v8; it used to be the clunkier EXPERIMENTAL_useProjectService) tells the parser to ask TypeScript for each file’s type info the same way your editor does — no separate lint-only tsconfig to maintain.

Turning rules off in place

Sometimes a rule is right in general and wrong for one specific line. You disable it inline with a comment — and you should treat every one of these as a small debt you’re explaining:

// eslint-disable-next-line no-console -- CLI tool, stdout is the UI
console.log(result);

/* eslint-disable no-unused-vars */
function handler(_req, _res, next) { next(); }
/* eslint-enable no-unused-vars */

The -- reason suffix is a real convention: text after -- is a comment explaining why. Turn on reportUnusedDisableDirectives in linterOptions and ESLint will flag disable comments that no longer suppress anything, so stale exceptions get cleaned up instead of accumulating forever.

Prettier: give up on style arguments

Prettier is an opinionated formatter, and the opinion is the feature. It parses your code into an abstract syntax tree — throwing away your original spacing entirely — and reprints it from scratch according to its own rules and your line-width setting. Because it starts from the AST, the output is deterministic: the same input always yields the same output, no matter how mangled the input was.

any messy inputconst a= {x:1,y :2 }foo (a)parse → ASTwhitespace discardedone canonical outputconst a = { x: 1, y: 2 };foo(a);
Prettier discards your formatting completely. It parses source into a syntax tree, forgets the original whitespace, and prints fresh output from the tree. Style becomes a property of the tool, not the typist.

The payoff is the end of bikeshedding. There is no “our team’s brace style” to debate because Prettier’s config surface is deliberately tiny — a handful of options like printWidth, semi, singleQuote, trailingComma. You cannot configure your way into a personal style, and that’s on purpose: the whole point is that nobody gets to have a style, so the discussion never happens.

// .prettierrc
{
  "printWidth": 100,
  "singleQuote": true,
  "trailingComma": "all"
}

You run it over the repo (npx prettier --write .), add a .prettierignore for generated files, and from then on formatting is a solved, invisible problem.

Prettier 3.6, released in June 2025, added an experimental high-performance CLI behind the --experimental-cli flag and two official parser plugins, @prettier/plugin-oxc and @prettier/plugin-hermes, built on the Rust-based Oxc parser. On a large TypeScript codebase the combination cut formatting time dramatically — reports of a 250k-line monorepo dropping from about two minutes to roughly twelve seconds. It’s opt-in and still experimental as of mid-2026, but it’s the answer to the main historical knock on Prettier: speed.

Keeping ESLint and Prettier out of each other’s way

There’s one classic conflict. ESLint historically shipped stylistic rules (semicolons, quotes, indentation) that overlap with what Prettier now owns. If both are active, ESLint flags a semicolon, Prettier removes it, and they fight in a loop. The fix is eslint-config-prettier: a config that turns off every ESLint rule that could conflict with a formatter. Add it last in your array and the boundary is clean — ESLint stops caring about looks, Prettier owns looks entirely.

ESLint — meaningno-unused-vars · eqeqeqno-floating-promisesreact-hooks/rules-of-hooksstylistic rules: switched OFFPrettier — layoutindentation · line widthquotes · semicolonstrailing commas · wrappingthe single source of styleeslint-config-prettier draws this line
The division of labor. eslint-config-prettier switches off ESLint's stylistic rules so the two tools never rewrite the same characters. ESLint keeps meaning; Prettier keeps layout.

Note you do not need eslint-plugin-prettier (running Prettier as an ESLint rule) — that’s slower and muddies the separation. Run them as two commands.

Biome: one fast tool instead of two

Biome takes a different bet: a single tool, written in Rust, that both lints and formats — replacing the ESLint + Prettier pair with one binary and one config file. Because it’s native code with a purpose-built parser, it’s fast enough that formatting and linting a big project feels instant, and there’s no plugin-resolution overhead or dependency tree to manage.

the classic pairESLint + plugins + configsPrettier + pluginstwo binaries · two configs ·a boundary to keep cleanorBiomelint · format · import-sortone Rust binarybiome.json
Biome collapses the two-tool setup into one binary. Same two jobs — lint and format — but one process, one config file, one dependency, and no ESLint-vs-Prettier boundary to configure.

Biome v2 — codenamed “Biotype,” released June 2025 — closed the biggest gap it used to have against ESLint: it became the first JavaScript/TypeScript linter to offer type-aware rules without running the TypeScript compiler, inferring enough type information on its own to power rules like noFloatingPromises. That inference doesn’t yet understand deeply complex types the way full typed linting does, but it covers a meaningful slice with none of the startup cost. v2 also added a plugin system (custom rules written in GritQL), monorepo support, configurable import sorting, and domains — rule groups that auto-enable based on your package.json, so if react is a dependency the React rules turn on by default without you listing them. By the v2.5 line, Biome shipped over 500 lint rules and cross-file analysis.

Setup is one file and a couple of commands:

// biome.json
{
  "formatter": { "enabled": true, "indentWidth": 2 },
  "linter": {
    "enabled": true,
    "rules": { "recommended": true }
  }
}
npx @biomejs/biome check --write .   # lint + format + fix, in one pass
npx @biomejs/biome format --write .  # just formatting
npx @biomejs/biome lint .            # just linting

There’s a third contender worth knowing by name: Oxlint (from the Oxc project), another Rust linter aimed at being a fast first-pass companion to ESLint rather than a full replacement, with a formatter (oxfmt) in alpha as of late 2025. The space is moving fast; the concepts here — lint vs. format, config as data, enforce everywhere — outlast whichever binary wins.

Making it automatic

A tool that catches problems only when someone remembers to run it catches almost nothing. The value comes from running these checks at three points, each a tighter net than the last. If a bad line slips past one, the next one catches it.

fastest feedback1 · Editorformat + fix on saveoptional · per-developerblocks the commit2 · Pre-commithook on staged filesshared · bypassablethe backstop3 · CIcheck-only, on every PRenforced · cannot skipa bad line missed by one gate is caught by the next
Three gates, tightening. Your editor fixes on save for instant feedback; the pre-commit hook blocks bad code from ever being committed; CI is the backstop that no local setup can bypass.

On save, in the editor

The tightest feedback loop is the one that runs as you type. Point your editor at the linter and formatter and have it fix on save — a misplaced import gets sorted, quotes normalize, the unused variable underlines itself the moment you stop using it. In VS Code that’s a few lines in .vscode/settings.json, checked into the repo so every contributor gets the same behavior:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  }
}

(For Biome the formatter id is biomejs.biome and the fix action is source.fixAll.biome.) This is per-developer and optional — someone using a different editor won’t have it — which is exactly why it can’t be the only gate.

On commit, with a pre-commit hook

The next gate runs when someone tries to commit. A Git pre-commit hook runs a script before the commit is recorded; if the script exits non-zero, the commit is aborted. The standard wiring is husky (installs the hook) plus lint-staged (runs tools only on the files you actually staged, so a two-file change doesn’t lint the whole repo).

// package.json
{
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,css,md}": ["prettier --write"]
  }
}

The husky hook itself is a one-liner that runs npx lint-staged. Now a commit that introduces a lint error is refused on the spot, before it ever reaches a branch. Fast, because it only touches staged files.

In CI, as the real gate

CI is the non-negotiable one. On every pull request, a workflow runs the same checks in check mode — verify, don’t rewrite — and a failure blocks the merge. Because it runs server-side, nobody can --no-verify their way past it.

# .github/workflows/quality.yml
name: quality
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx prettier --check .   # fails if anything is unformatted
      - run: npx eslint .             # fails on any error-level rule

The key detail is --check (Prettier) and plain eslint with no --fix: CI verifies and reports, it never rewrites. Formatting is fixed by the developer or the pre-commit hook; CI’s job is to say yes or no. With Biome it collapses to a single npx @biomejs/biome ci ..

Wire the same commands into package.json scripts so humans and CI run identical checks — no “works on my machine” drift:

{
  "scripts": {
    "lint": "eslint .",
    "format": "prettier --write .",
    "format:check": "prettier --check ."
  }
}

Why bother with all of this

Step back from the config files and the point is simple. Every minute a reviewer spends on indentation is a minute not spent on the logic that actually breaks in production. Style debates feel like caring about quality; they’re mostly a tax on attention. By making a machine own consistency — enforced identically on every save, every commit, and every PR — you free the humans to review the things only humans can: is this the right approach, does this edge case hold, will this scale.

Formatting stops being a discussion. Whole categories of bugs get caught before the code runs. And a diff shows only meaningful changes, because nobody’s reformatting reshuffled half the file. That’s the entire return on a day of setup you do once.

Summary

  • Linting and formatting are different jobs. Formatters standardize how code looks and never change behavior; linters flag what code means — likely bugs and forbidden patterns. Run both; give them separate territory.
  • ESLint uses flat config (eslint.config.js) — an ordered array of config objects with files, rules, plugins, languageOptions, and extends. Severities are off/warn/error (0/1/2); only error fails the build.
  • Typed linting via typescript-eslint (projectService: true) catches type-dependent bugs like floating promises, at the cost of being noticeably slower — scope it to your TypeScript files.
  • Prettier is opinionated on purpose. It reprints from the AST, so output is deterministic and style debates end. Pair it with eslint-config-prettier so ESLint stops policing looks. Version 3.6 added an experimental fast CLI and Oxc-based plugins.
  • Biome is a single Rust binary that lints and formats, fast, with one config file. Biome v2 added compiler-free type-aware rules, plugins, and domains. Great default for new projects; ESLint still wins on plugin breadth and deep typed linting.
  • Enforce at three gates: fix-on-save in the editor (instant, optional), a pre-commit hook via husky + lint-staged (fast, bypassable), and CI in check-mode (the real, unskippable gate). Keep the commands identical across all three.