DEV Community

Cover image for The Two-Tool Problem: ESLint, Prettier, and the Case for a Unified Toolchain
Rajat Kumar
Rajat Kumar

Posted on

The Two-Tool Problem: ESLint, Prettier, and the Case for a Unified Toolchain

Deep dive · Tooling · ~18 min read · ESLint · Prettier · Biome · TypeScript

Most JavaScript projects ship with both ESLint and Prettier without anyone really understanding why two separate tools are needed — or why combining them into one is harder than it looks. This is the post that should have existed when I first set up a new project and stared at four config files doing roughly the same thing.


Table of Contents

  1. The right mental model, first
  2. ESLint: what it actually does under the hood
  3. Prettier: why it reprints instead of fixing
  4. The overlap zone: where things get messy
  5. Configuring both properly in 2026
  6. Why one tool couldn't do this for so long
  7. Biome: the serious attempt at unification
  8. A real comparison across a codebase
  9. What Biome still cannot do
  10. Verdict and migration strategy

The right mental model, first

The confusion starts because both tools ship as dev dependencies, both produce errors in your terminal, both integrate with your editor, and both run in CI. They touch the same files. So the reasonable assumption is that they're doing the same thing — just with different opinions about it.

That assumption is wrong, and it's the root of every misconfiguration I've seen in the wild.

The cleanest way to think about this: ESLint and Prettier are answering completely different questions.

Dimension ESLint Prettier
Core question Is this code correct and safe? Does this code look consistent?
Domain Semantics & logic Aesthetics & layout
Operates on AST (abstract syntax tree) Raw text → reprinted output
Can it catch bugs? ✅ Yes ❌ Never
Output Errors & warnings with location Reformatted file (always)
Auto-fixable? Partially — many rules aren't auto-fixable Always — the whole point
Configurable? Deeply — write custom rules, use plugins Barely — intentionally opinionated
Framework-aware? Yes, via plugins (React, Angular, Vue, etc.) Language-aware only, not framework-aware

Neither is a subset of the other. They're orthogonal tools that happen to share a pipeline step. Internalizing that distinction makes every configuration decision obvious.


ESLint: what it actually does under the hood

ESLint parses your source into an Abstract Syntax Tree — a structured, in-memory representation where every piece of code becomes a typed node. A function call is a CallExpression node. A variable declaration is a VariableDeclaration node. ESLint then runs a traversal over this tree, calling registered rule handlers at each node type.

This is why ESLint can catch things like:

  • A variable that's declared but never referenced (no-unused-vars)
  • A promise-returning function whose return value is discarded (no-floating-promises)
  • A React hook called inside a conditional (react-hooks/rules-of-hooks)
  • Potential XSS via unsanitized innerHTML assignment (no-unsanitized)
  • TypeScript type errors surfaced without running tsc (@typescript-eslint/no-unsafe-assignment)

These are semantic problems. The tool is reasoning about what your code means and whether that meaning is sound — not what it looks like on screen.

// ESLint sees this as: VariableDeclaration → VariableDeclarator → Identifier("result")
// It knows "result" is never used elsewhere in scope. Prettier has no concept of scope.

async function fetchUser(id: string) {
  const result = fetch(`/api/users/${id}`);  // no-floating-promises: missing await
  const userData = await getProfile(id);     // no-unused-vars: userData declared, never read

  return null;
}
Enter fullscreen mode Exit fullscreen mode

ESLint also runs scope analysis — it maintains a symbol table as it traverses, tracking where variables are defined and referenced. That's what makes rules like no-shadow and no-use-before-define possible. Prettier processes the same file as text and reprints it — it has no notion of a symbol table.

The plugin architecture that makes ESLint powerful

ESLint's real strength is that rule-writing is a public API. A plugin is just a Node.js module that exports rules, each of which is a visitor object — a set of handlers keyed by AST node type. The React Hooks plugin, for example, registers a visitor on CallExpression nodes, checks whether the callee looks like a hook, and then traverses the call stack to validate that hooks are only called from the top level of a function component.

This is why the ecosystem is so rich. Any team can encode their own invariants — domain rules, API usage contracts, security constraints — and enforce them statically. That kind of extensibility is architecturally incompatible with what Prettier is trying to do.


Prettier: why it reprints instead of fixing

Prettier also parses your code into an AST — but then it does something fundamentally different with it. It throws away your original formatting entirely and reprints the file from scratch using its own layout algorithm. Every run of Prettier on the same logical code produces the exact same output. This is called idempotent formatting.

"Prettier takes your code and reprints it from scratch by taking the line length into account, wrapping code when necessary."
— Prettier docs

The example below shows what this means in practice. You can hand Prettier wildly different formatting of the same logical code, and it produces identical output every time.

Before (anything goes):

const     user   =   {
name:    "Rajat",
    role:   "engineer",
  active:true
}

function greet(u,prefix,suffix){
  return `${prefix} ${u.name} ${suffix}`
}
Enter fullscreen mode Exit fullscreen mode

After Prettier (always the same output):

const user = {
  name: "Rajat",
  role: "engineer",
  active: true,
};

function greet(u, prefix, suffix) {
  return `${prefix} ${u.name} ${suffix}`;
}
Enter fullscreen mode Exit fullscreen mode

Prettier intentionally offers almost no configuration. You can set quote style, semicolons, tab width, trailing commas, and print width — that's essentially it. This is by design. The whole value proposition is that formatting ceases to be a decision. No more code review comments about semicolons. No more team debates about line length. Prettier decides, everyone accepts it, and you move on to the actual work.

Note: Prettier's inflexibility is the feature. If it let you configure everything, teams would spend time configuring it — defeating the entire purpose of having a formatter in the first place. This is a conscious and correct tradeoff.


The overlap zone: where things get messy

Here's where the confusion compounds. ESLint has always had stylistic rules — rules about indentation, quote style, semicolons, spacing — that overlap with what Prettier manages. For years, linters were the only tool doing any formatting, so these rules existed for a reason. But once Prettier entered the picture, you now have two systems with conflicting opinions about the same bytes.

A classic conflict: ESLint's quotes rule set to "double", Prettier's singleQuote: true. ESLint rewrites to double quotes, Prettier rewrites to single, they fight forever and your editor goes insane on every save.

# The classic conflict — what developers actually experienced

# ESLint (with quotes: ["error", "double"]) fixes this:
const name = 'Rajat';   →   const name = "Rajat";

# Prettier (with singleQuote: true) then immediately fixes it back:
const name = "Rajat";   →   const name = 'Rajat';

# Infinite loop. Editor goes to war with itself on every save.
Enter fullscreen mode Exit fullscreen mode

The solution the community landed on is eslint-config-prettier — a shareable ESLint config that disables all ESLint rules that conflict with Prettier. You put it last in your config array so it overrides anything that might fight Prettier. Think of it as a white flag: ESLint fully surrenders the formatting territory to Prettier.

Warning: eslint-plugin-prettier — which runs Prettier as an ESLint rule — is a different beast and generally the wrong approach. It chains Prettier's output into ESLint's pipeline, creating double-parsing overhead, slower CI runs, and confusing error messages that blend linting and formatting in the same output. The ESLint team and typescript-eslint both recommend against it.


Configuring both properly in 2026

ESLint's flat config (eslint.config.js) is now the default, with defineConfig() from eslint/config being the current recommended helper as of ESLint v9. The old tseslint.config() wrapper is deprecated. Here's a well-structured setup for a TypeScript + React project:

// eslint.config.js
// @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 prettierConfig from "eslint-config-prettier";

export default defineConfig(
  // 1. Base JS rules
  eslint.configs.recommended,

  // 2. TypeScript rules (strict = recommended + extra correctness rules)
  ...tseslint.configs.strictTypeChecked,

  // 3. Type-aware rules require pointing at your tsconfig
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },

  // 4. React Hooks rules
  {
    plugins: { "react-hooks": reactHooks },
    rules: reactHooks.configs.recommended.rules,
  },

  // 5. Your own overrides
  {
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/consistent-type-imports": "error",
      "no-console": ["warn", { allow: ["warn", "error"] }],
    },
  },

  // 6. Prettier LAST — disables all conflicting ESLint style rules.
  // This is a config object (not a plugin), so it just turns off rules.
  prettierConfig,
);
Enter fullscreen mode Exit fullscreen mode
// .prettierrc
{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100,
  "arrowParens": "always",
  "endOfLine": "lf"
}
Enter fullscreen mode Exit fullscreen mode

For your package.json scripts, run them separately — never chain Prettier through ESLint:

{
  "scripts": {
    "lint":           "eslint . --max-warnings 0",
    "lint:fix":       "eslint . --fix",
    "format":         "prettier --write .",
    "format:check":   "prettier --check .",
    // CI: format check first (fast), then full lint
    "ci:quality":     "prettier --check . && eslint . --max-warnings 0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Tip: Run Prettier before ESLint in CI. Prettier is dramatically faster (formatting doesn't need to build a type-aware AST), so failing fast on a formatting issue saves the time of running the full ESLint pass on badly-formatted code.

The config files you end up with

In a typical TypeScript monorepo this expands to: eslint.config.js, .prettierrc, .prettierignore, plus any workspace-level overrides. That's already 3–4 files, with dependencies like @typescript-eslint/parser, typescript-eslint, eslint-config-prettier, and prettier itself — each with their own peer dependency trees. For a bare create-next-app this routinely pulls in 60–80 packages just to make linting and formatting work.

This is not a deal-breaker. It's tooling; it's meant to be set up once. But it does mean there's real maintenance surface area — and it's the problem that Biome is trying to solve at the architectural level.


Why one tool couldn't do this for so long

The obvious follow-up question: why wasn't this solved years ago? If both tools parse an AST, why can't one tool do both passes?

The answer is architectural, and it's worth understanding — because it also explains why Biome's design is genuinely novel rather than just marketing.

Formatting and linting have opposite design requirements

A good formatter must be deterministic and opinionated. You want exactly one correct output for any given input. The moment you start making rules configurable, you invite teams to configure them differently, which reintroduces the formatting debates the tool was meant to eliminate. Prettier's intentional inflexibility is the product.

A good linter must be extensible and configurable. Every codebase has different invariants — a fintech product has security rules a portfolio site doesn't need, a React app has hook rules that an Angular app doesn't care about. You need a plugin system, per-file overrides, custom rule authoring. If you lock the linter down the way Prettier locks the formatter down, it becomes useless for most real projects.

Core tension: The qualities that make a great formatter (inflexible, opinionated, deterministic) are architecturally opposed to the qualities that make a great linter (extensible, configurable, context-sensitive). Building one tool that does both well requires solving this at the design level — not just merging two CLIs into one binary.

The performance problem with naive combinations

When you use eslint-plugin-prettier (the approach that runs Prettier as an ESLint rule), you're forcing ESLint to:

  1. Parse the file into an AST
  2. Run all linting rules
  3. Apply auto-fixes (including Prettier's formatting changes)
  4. Re-parse the now-modified file
  5. Run rules again to verify fixes didn't introduce new violations

On a project with type-aware TypeScript rules, each parse-and-lint cycle is already slow because it needs to construct a full type program. Running Prettier inside that cycle multiplies the pain. This is why, on large codebases, eslint --fix with Prettier embedded can take 30–60 seconds — and why some teams disable type-aware rules entirely just to keep CI manageable.


Biome: the serious attempt at unification

Biome (originally forked from the Rome project) is the most credible attempt to build a unified toolchain from scratch. It's written in Rust, ships as a single binary, and takes a fundamentally different architectural approach that makes the one-tool dream actually tractable. As of early 2026, it's at v2.4 and has surpassed 15 million monthly downloads.

The key architectural insight: parse once, use everywhere

Biome parses your code into its own CST (Concrete Syntax Tree — preserves more information than an AST, including whitespace) exactly once, then runs both the formatter and the linter over it in separate passes using the same in-memory tree. This eliminates the redundant parsing that makes chained tools slow.

The formatter and linter are implemented as separate visitors over the same tree, so they don't interfere with each other's operation — but they also don't need to pay the parsing cost twice.

// biome.json (v2.4)
{
  "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",

  "organizeImports": {
    "enabled": true
  },

  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf"
  },

  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedVariables": "error",
        "useExhaustiveDependencies": "error"
      },
      "suspicious": {
        "noConsoleLog": "warn",
        "noExplicitAny": "error"
      },
      "style": {
        "useTemplate": "error",
        "noParameterAssign": "warn"
      },
      "performance": {
        "noDelete": "warn"
      }
    }
  },

  "javascript": {
    "formatter": {
      "quoteStyle": "double",
      "semicolons": "always",
      "trailingCommas": "all",
      "arrowParentheses": "always"
    }
  },

  "files": {
    "ignore": ["dist", ".next", "node_modules", "coverage"]
  },

  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  }
}
Enter fullscreen mode Exit fullscreen mode

That single file replaces: eslint.config.js, .prettierrc, .prettierignore, and the coordination glue between them.

The commands

npx @biomejs/biome check .          # lint + format check (reports only)
npx @biomejs/biome check --write .  # lint + format + auto-fix
npx @biomejs/biome format --write . # format only
npx @biomejs/biome lint --write .   # lint + auto-fixable rules only

# Migration from an existing project:
npx @biomejs/biome migrate eslint --write   # reads eslint.config.js / .eslintrc and ports rules
npx @biomejs/biome migrate prettier --write # reads .prettierrc and ports options
Enter fullscreen mode Exit fullscreen mode

Error output that's actually useful

One underrated improvement: Biome's error messages are designed to explain the intent of a rule, not just flag the location. Compare:

ESLint:

✖  1 problem (1 error, 0 warnings)

src/utils.ts
  12:7  error  'result' is assigned a value but never used
        @typescript-eslint/no-unused-vars
Enter fullscreen mode Exit fullscreen mode

Biome:

lint/correctness/noUnusedVariables

 This variable is declared but never used.

10  function processData(items) {
11    const TAX = 0.08;
>  12    const result = transform(items);
          ^^^^^^
13    return items.length;
14  }

 Unused variables are often a sign of incomplete refactoring.
Enter fullscreen mode Exit fullscreen mode

The diagnostic gives you the rule category, a contextual code frame, and an explanatory note about why the rule exists. It's a small thing that adds up fast when you're onboarding a team.


A real comparison across a codebase

Speed benchmarks across the community are consistent. On a Next.js + TypeScript project with 300+ source files, the difference is stark. Note that these are relative numbers — exact timing depends on machine, file count, and rule complexity.

Tool Time (312 files) Relative
ESLint + Prettier ~28s baseline
Biome check ~1.4s ~20× faster
Oxlint (lint only) ~0.6s ~50× faster

This matters for two practical reasons: CI pipeline time (where every second costs money and developer attention), and editor feedback latency (where slow linting breaks the tight write-see-error-fix loop that makes static analysis valuable in the first place).

Package weight comparison

# ESLint + Prettier setup for TypeScript + React
$ npm install --save-dev \
    eslint @eslint/js typescript-eslint \
    eslint-plugin-react-hooks \
    eslint-config-prettier prettier

added 127 packages   # config files: 3-4

# ─────────────────────────────────────

# Biome equivalent
$ npm install --save-dev --save-exact @biomejs/biome

added 1 package      # config files: 1
Enter fullscreen mode Exit fullscreen mode

Note: The package count difference is somewhat misleading — those 127 packages are mostly shared transitive deps that other tools also use. But the config file consolidation is genuinely significant in large repos and monorepos, where config drift between workspaces is a real problem.


What Biome still cannot do

Biome is production-ready for most TypeScript/React/Next.js projects as of v2.4. But there are genuine gaps worth knowing before you migrate. This isn't a reason to avoid Biome — it's information to make an honest decision.

Plugin ecosystem — ESLint has a decade of community plugins: eslint-plugin-testing-library, eslint-plugin-security, eslint-plugin-unicorn, and hundreds more. Biome's plugin story via GritQL (introduced in v2.0) is live and growing, but not yet at parity with ESLint's ecosystem depth. If you depend on specific plugins, check coverage first at biomejs.dev/linter/json/rules.

Deep type-aware rulestypescript-eslint in type-checked mode can run rules against your full type program, catching things like no-unsafe-assignment across module boundaries. Biome's type inference engine (expanded in v2.4 to resolve Record<K, V> types and improve noFloatingPromises accuracy) is improving but not yet at full parity with typescript-eslint's depth.

Language coverage — Prettier formats Vue SFCs, SCSS, YAML, Markdown, GraphQL, and MDX. Biome v2.4 covers JS, TS, JSX, TSX, JSON, CSS, and GraphQL, with experimental support for Vue, Svelte, and Astro. SCSS support is in active development. If your project formats Markdown or YAML through Prettier, you'll need to keep it for those file types or use a separate tool.

Angular / Vue template lintingangular-eslint parses Angular templates with Angular's own compiler and runs template-aware rules. There's no Biome equivalent yet. Vue's eslint-plugin-vue similarly operates on SFC templates. If you're on Angular or Vue and relying on template linting, keep ESLint for now.

eslint-disable migration — Biome uses biome-ignore comments, not eslint-disable. During migration, Biome reveals how many suppression comments your codebase has accumulated — and they won't carry over automatically. This can surface a lot of latent tech debt at once. Factor that into your migration sprint planning.

JSON-only config — Biome's config is JSON (or JSONC). You can't write it in JavaScript, which means no dynamic config based on environment variables or computed values — a minor but real limitation for projects with complex per-environment rule sets.

The honest hybrid strategy

For projects that need the ESLint plugin ecosystem — particularly Angular, Vue, or any project with deep security rule requirements — the pragmatic path is:

  • Use Biome for formatting and the core lint rules it covers (it's faster and cleaner)
  • Keep ESLint for the specific plugins that Biome doesn't have equivalents for yet
  • Drop Prettier entirely (Biome's formatter scores 97%+ Prettier compatibility and is significantly faster)

You still cut the config complexity roughly in half and get most of the performance benefit, without betting entirely on a newer tool for rules that matter to your project.


Verdict and migration strategy

After digging through the architecture and the practical tradeoffs, here's where I land.

The ESLint + Prettier split is not redundancy — it's two genuinely different tools solving two genuinely different problems. Understanding the split makes you a better engineer regardless of which tools you use. But the configuration overhead and performance cost of running them as separate pipelines is a real problem, and Biome solves it at the right level — not by gluing tools together, but by redesigning the architecture.

Situation Recommendation
New project Start with Biome. For TypeScript + React/Next.js, it covers 95%+ of what ESLint + Prettier gave you, at a fraction of the config cost.
Existing TS/React Migrate incrementally. Run biome migrate eslint + biome migrate prettier, validate, then remove the old configs over a sprint or two.
Angular project Keep ESLint with angular-eslint. Replace Prettier with Biome's formatter only. Evaluate full migration as Biome's Angular support matures.
Heavy plugin deps Stay on ESLint for the rules you can't replace. Drop Prettier, use Biome as your formatter. You still win on the formatting side.
Monorepo Biome's single-binary, single-config model is particularly compelling here. Config drift between workspaces is a common pain it directly solves.

The bigger takeaway is conceptual. For years, "use ESLint and Prettier together" was tribal knowledge — you did it because everyone else did it, not because you understood the why. Understanding that ESLint reasons about meaning and Prettier reasons about appearance — and that these require fundamentally different architectures — is the insight that makes every tooling decision from here obvious.

Biome is what happens when you start from that insight and build for it from day one, in Rust, with a unified AST and a shared parse tree. It's not perfect yet. But the architectural foundation is correct, and the trajectory — 15 million monthly downloads, experimental Vue/Svelte/Astro support, a functioning GritQL plugin system, and a clear 2026 roadmap — makes the direction unmistakable.


Top comments (0)