Dev-note·Sep 14, 2026·11 min read

Mình bỏ ESLint và Prettier để chuyển hết sang Biome

Repo mình giờ chỉ còn đúng một công cụ lo cả format, lint lẫn sắp xếp import. Bài này kể Biome là gì, mình được gì sau khi chuyển, và quan trọng hơn là mất gì, vì nó có mất thật.

Q
QuyenTMThợ code

Mở bài

Một dự án JavaScript/TypeScript bình thường lâu nay phải nuôi ít nhất hai công cụ: ESLint để soi lỗi, Prettier để định dạng. Thêm một plugin cho ESLint hiểu TypeScript, một plugin nữa để hai đứa nó đừng cãi nhau, một file config cho mỗi đứa, một file ignore cho mỗi đứa.

Chuyện đó bình thường tới mức chẳng ai hỏi lại. Mình cũng vậy, suốt nhiều năm.

Repo hiện tại của mình là monorepo hơn một nghìn file TypeScript, gồm một backend NestJS, ba frontend React và một service Go. Tuần rồi mình dọn xong bước cuối: gỡ Prettier ra, và từ đó cả repo chỉ còn một công cụ duy nhất lo format, lint và sắp xếp import. ESLint thì đã ra đi từ trước.

Công cụ đó là Biome.

Bài này mình kể nó là gì, chuyển sang thì được gì, và mất gì. Phần mất mình để hẳn một mục, vì nó có mất thật chứ không phải kiểu "nhược điểm: quá tốt".

Thân bài

1. Biome là cái gì

Nói ngắn gọn: một toolchain viết bằng Rust, gom mấy việc vốn phải chia cho nhiều công cụ vào một binary duy nhất.

Việc Trước đây Giờ
Định dạng code Prettier biome format
Soi lỗi, code smell ESLint + đống plugin biome lint
Sắp xếp import plugin của ESLint hoặc Prettier có sẵn
Chạy hết trong CI ba bốn lệnh biome ci

Một binary, một file config, không cần plugin để nó hiểu TypeScript, và không cần Node để chạy, nó có cả bản thực thi độc lập.

Điểm hay ăn tiền nhất là tốc độ, vì nó chạy đa luồng chứ không đơn luồng trên Node như ESLint. Con số mình đo trên repo của mình:

Checked 1064 files in 178ms.

Một nghìn file TypeScript, chưa tới hai phần mười giây. Cùng khối lượng đó, ESLint với @typescript-eslint thường ngốn vài chục giây, vì nó phải phân tích lại mã nguồn bằng một parser viết bằng JavaScript.

2. Nó hỗ trợ ngôn ngữ nào

Chỗ này nên xem kỹ trước khi quyết, vì nó là ranh giới rõ ràng nhất giữa "hợp" và "không hợp":

Ngôn ngữ Tình trạng
JavaScript, TypeScript, JSX, TSX đầy đủ
JSON, JSONC đầy đủ
CSS đầy đủ
GraphQL đầy đủ
HTML, SVG dùng được, phần định dạng còn thử nghiệm
Vue, Svelte, Astro còn thử nghiệm
SCSS, Markdown, YAML chưa hỗ trợ

Mình có kiểm chứng lại bảng này bằng cách tạo mười bốn file thử đủ loại rồi cho nó chạy, chứ không chỉ đọc tài liệu. Markdown, YAML và SCSS thì nó bỏ qua thật, không đụng tới.

Còn Go, Dart, Rust, Python thì hoàn toàn không, vì Biome là công cụ thuần hệ web. Service Go trong repo mình vẫn dùng gofmt và golangci-lint như cũ.

3. Config trông như thế nào

Đây là chỗ mình thích nhất. Toàn bộ cấu hình cho cả monorepo nằm trong một file ở thư mục gốc:

{
  "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
  "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
  "assist": { "actions": { "source": { "organizeImports": "on" } } },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "semicolons": "always",
      "quoteStyle": "single",
      "trailingCommas": "all"
    }
  },
  "linter": {
    "enabled": true,
    "rules": { "preset": "recommended" }
  }
}

Vài chỗ đáng chú ý:

useIgnoreFile: true cho nó đọc thẳng .gitignore, khỏi phải chép lại danh sách node_modules, dist, build sang một file ignore riêng. Thêm thư mục build mới thì chỉ sửa một chỗ.

Phần formatter mình chép đúng các giá trị trong .prettierrc cũ, nên đổi qua gần như không đổi phong cách code.

Và nếu bạn đang dùng ESLint hay Prettier, không cần ngồi dịch config bằng tay, chạy biome migrate là nó tự đọc cấu hình cũ rồi sinh ra cấu hình tương đương. Repo mình có mấy chục dòng comment tiếng Việt giải thích từng luật bị tắt, nó giữ nguyên không sót chữ nào.

4. Chuyển xong thì được gì

Cái được lớn nhất không phải tốc độ, mà là số thứ phải nhớ giảm đi.

Trước: hai dependency, hai file config, hai file ignore, hai lệnh trong package.json, hai bước trong CI, hai dòng trong lint-staged. Giờ mỗi loại còn một.

Cụ thể trong repo mình:

  • Xoá .prettierrc.json, .prettierignore, dependency prettier.
  • Bảy package con trước đây mỗi cái khai một bản Biome riêng, giờ gộp về một dependency duy nhất ở thư mục gốc. Muốn nâng version thì sửa một dòng, không phải bảy.
  • Hook lúc commit từ hai dòng gọi Prettier còn đúng một dòng biome check --write.
  • CI thêm đúng một bước biome ci . là gác cả format, thứ tự import lẫn lint.

Cái bước CI đó mới là thứ khiến mình dứt điểm chuyển. Trước khi dọn, mình chạy thử prettier --check lên toàn repo và nhận về 14 file đang lệch chuẩn, chúng trôi được vào nhánh chính vì CI xưa nay chỉ chạy lint, chưa bao giờ kiểm tra format. Tức là mình có một quy ước mà không có ai gác.

Còn số lỗi lint thì từ 77 xuống 0, phần lớn không phải do mình sửa mà do bản mới phân loại lại mức độ nghiêm trọng cho hợp lý hơn. Một cái cổng lúc nào cũng đỏ thì chẳng khác gì không có cổng, vì rồi chẳng ai buồn nhìn nữa.

5. Mất gì

Markdown và YAML từ nay không ai format. Repo mình có 34 file .md và 34 file .yaml trước đây do Prettier lo. Muốn về một công cụ duy nhất thì phải chấp nhận bỏ. Mình chọn bỏ vì format markdown tự động hay phá bảng, còn đống YAML nặng ký nhất là helm chart thì vốn đã nằm trong danh sách loại trừ do chứa cú pháp template. Nhưng ai cần format hai loại này thì đây là điểm dừng: hoặc giữ Prettier riêng cho chúng, hoặc chấp nhận không có.

Hệ sinh thái plugin thì không thể so. ESLint có plugin cho gần như mọi thư viện. Biome có sẵn kha khá luật thông dụng, nhưng nếu bạn đang sống nhờ một plugin đặc thù nào đó thì phải kiểm tra trước xem có luật tương đương không, đừng chuyển rồi mới phát hiện mất.

Format không giống Prettier 100%. Mình đo thử: chạy lên bản sao toàn repo thì 26 trên 1061 file bị đổi. Nhưng nhớ 14 file lệch chuẩn ở trên chứ? Chúng nằm trong số 26 đó. Trừ đi thì khác biệt thật giữa hai formatter chỉ khoảng một tá file, và toàn chuyện xuống dòng ở decorator nhiều tham số:

- @OneToMany(() => OrderItemOrmEntity, (item) => item.order, { cascade: true, eager: true })
+ @OneToMany(
+   () => OrderItemOrmEntity,
+   (item) => item.order,
+   { cascade: true, eager: true },
+ )

Bài học nhỏ ở đây: nhớ chạy prettier --check lấy mốc trước khi đổi. Không có mốc đó, mình đã quy cả 26 file cho công cụ mới và kết luận sai gấp đôi sự thật.

6. Vài chỗ vướng nếu bạn dùng NestJS

Phần này dành riêng cho ai đang ở stack giống mình, các bạn khác đọc lướt cũng được.

NestJS đặt decorator lên tham số (@InjectDataSource() ds). Biome mặc định coi đó là cú pháp chưa chuẩn và từ chối phân tích, đổ ra hàng trăm lỗi giả. Bật cờ này là xong:

"javascript": { "parser": { "unsafeParameterDecoratorsEnabled": true } }

Nguy hiểm hơn nhiều là luật useImportType: luật đổi import thành import type cho gọn. Nest đọc kiểu tham số constructor qua metadata sinh lúc biên dịch, mà import type bị xoá sạch khi biên dịch, nên metadata thành Object và dependency injection chết. Cái ác của lỗi này là nó lọt qua hết mọi lưới quen thuộc: biên dịch xanh, unit test xanh, app chỉ chết lúc khởi động thật. Nếu dự án bạn bật emitDecoratorMetadata thì tắt luật đó đi, và viết một test riêng chốt chặn để lần sau không ai bật lại.

Còn dùng Tailwind thì nhớ thêm "css": { "parser": { "tailwindDirectives": true } }, không thì mỗi file CSS đầu vào đổ một lỗi cú pháp.

Cuối cùng là một cái bẫy vặt nhưng suýt làm mình tin vào kết quả sai: sau khi gỡ dependency khỏi các package con rồi cài lại, trình quản lý gói không dọn symlink cũ trong node_modules/.bin. Lệnh vẫn tìm thấy binary bản cũ và chạy nó trong im lặng. Nên gỡ hay nâng công cụ xong, việc đầu tiên là bắt nó tự khai version, chứ đừng chỉ xem nó có chạy không.

7. Lười đọc thì có đường tắt

Nếu bạn ngại ngồi làm tay từng bước, hoặc đơn giản là muốn thử xem repo mình chuyển được không trước khi bỏ công, thì copy nguyên cái prompt dưới đây ném vào repo rồi bảo Claude Code (hay Cursor, Codex, con nào cũng được) chạy.

Mình viết nó sau khi tự đi hết một lượt, nên mấy cái bẫy kể ở trên đều đã gói sẵn trong đó: decorator trên tham số, useImportType giết dependency injection, Tailwind, symlink cũ, Markdown/YAML.

Hai chỗ mình cố ý viết vào và khuyên đừng xoá: bắt nó đo trước rồi mới báo cáo, và bắt nó dừng lại hỏi trước khi sửa thật. Một cú đổi format quét cả nghìn file mà để agent tự tung tự tác thì lúc review bạn chẳng biết đường nào mà lần.

Migrate this repo to Biome v2 as the ONE tool for formatting, linting and import sorting.
Remove ESLint and Prettier entirely. Goal: one binary, one config file, one source of truth.

## Ground rules

1. SURVEY FIRST, EDIT LATER. Read package.json (root + every workspace), the existing
   lint/format configs, the CI workflow and the pre-commit hook. Tell me the current state
   before you touch a single file.
2. EXPERIMENT IN A SANDBOX FIRST. Copy the source (excluding node_modules) to a temp directory,
   install the latest Biome there, run `biome migrate`, and measure for real. Do NOT modify the
   repo while researching.
3. MEASURE, DON'T GUESS. Every number you report must come from a command you actually ran.
   Where you haven't verified something, say so explicitly.
4. SEPARATE PRE-EXISTING FAILURES FROM ONES YOU CAUSED. Before accepting blame, verify by
   restoring the original file and re-running. Report pre-existing breakage; don't fix it
   outside the agreed scope.
5. If I'm on the main branch, tell me and ask before branching. Never commit or push on your own.

## What to do

**Step 1 — Research and report (change nothing):**
- Which Biome version is in use (if any), and what the latest is.
- Run `biome migrate` on the copy and show me the resulting config.
- Compare lint error counts before/after the upgrade, broken down by rule.
- Measure the cost of dropping Prettier: run `biome format --write` on the copy using settings
  that mirror the current .prettierrc, then count changed files and lines. IMPORTANT: run
  `prettier --check` on the original repo first — files that were already off the Prettier
  standard must not be counted as "Biome differs from Prettier".
- If ESLint is in use, map every rule that has no Biome equivalent and tell me what coverage
  I lose. Do not pretend the migration is lossless if it isn't.
- List exactly what can be deleted, what CANNOT, and the cost of each.
- Stop and ask me before doing the real work.

**Step 2 — Removal checklist.** Hunt these down by grepping the whole repo, not from memory:
- [ ] `prettier`, `eslint`, every `eslint-plugin-*`, `eslint-config-*`, `@typescript-eslint/*`
      in devDependencies — root AND every workspace package.
- [ ] Config files: `.prettierrc*`, `.prettierignore`, `.eslintrc*`, `eslint.config.*`,
      `.eslintignore`, and any `prettier` / `eslintConfig` key inside package.json.
- [ ] Scripts in package.json that invoke the old tools (`format`, `format:check`, `lint`).
- [ ] The lint-staged config and the husky hooks.
- [ ] CI steps that call the old binaries.
- [ ] Editor config: `.vscode/settings.json` (`editor.defaultFormatter`, format-on-save),
      `.vscode/extensions.json` recommendations, `.idea/` inspection profiles.
- [ ] Docs, READMEs, CONTRIBUTING and any agent/skill instruction file that still tells people
      to run the old tools — these silently keep the dead tool alive.
- [ ] After reinstalling: confirm the lockfile no longer resolves the removed packages, and that
      the leftover `node_modules/<tool>` directory is gone (a stale directory means the install
      didn't actually prune — re-run a clean install before you declare it removed).
Report the checklist back with each line marked done or explicitly not applicable.

**Step 3 — Execute the switch:** write the v2 config, hoist Biome to the root as a single
dependency, rewire lint-staged to `biome check --write`, add a formatting gate to CI
(`biome ci .`), and update every doc that named the old tools.

**Step 4 — Prove it.** Run all of: typecheck, the full test suite, AND actually boot the app.
Green typecheck plus green tests is NOT sufficient evidence of safety.

**Step 5 — Two commits:** one for the tooling change (reviewable), one for the formatting and
import sweep (huge diff, but nobody needs to read it line by line).

## Traps that bit me on another repo — check whether this one has them

- **Parameter decorators (NestJS)**: Biome refuses to parse them and emits hundreds of bogus
  `parse` errors. Needs `javascript.parser.unsafeParameterDecoratorsEnabled: true`.
- **`useImportType` vs dependency injection**: Nest reads constructor parameter types from
  `design:paramtypes`; `import type` is erased at compile time, so the metadata becomes `Object`
  and DI breaks. Worst symptom: typecheck green, tests green, app won't boot. If the repo uses
  `emitDecoratorMetadata`, turn `useImportType` OFF and add a test that guards DI metadata.
- **Parameter properties** (`constructor(private readonly x: X)`): Biome falsely reports them as
  unused. Disable `noUnusedVariables` for that scope instead of sprinkling `biome-ignore` on
  every constructor.
- **Tailwind in CSS**: needs `css.parser.tailwindDirectives: true`, otherwise every CSS entry
  file produces a parse error.
- **Markdown and YAML**: Biome does NOT support them yet. Ask me whether to drop formatting for
  those files or keep Prettier just for them. Do not decide this on your own.
- **Stale workspace symlinks**: after removing a dependency from sub-packages and reinstalling,
  the package manager may NOT clean up the old `node_modules/.bin/<tool>`, and commands will
  silently keep running the old version. Verify the binary reports the version you expect.
- **`bunx`/`npx` pulls a different version than the repo pins**: always invoke the binary from
  node_modules.
- **New v2 rules** (e.g. the a11y rule `noStaticElementInteractions`): if the code already has a
  `biome-ignore` for a related rule with a valid reason, ADD the new rule name to that same line
  (v2 accepts several rules per line). Don't rewrite working code just to satisfy a linter.
- **`organizeImports`**: verify experimentally instead of assuming it's dangerous. Check whether
  side-effect imports (`import 'reflect-metadata'`) get moved — Biome doesn't reorder across
  them, but measure it yourself before concluding either way.

## How to report

Before/after tables with real numbers. State clearly which of the three each claim is:
verified / inferred / not yet checked. Name what's still broken; don't dress it up.

Kết bài

Mình chuyển vì một lý do đơn giản: hai công cụ cho một việc là hai thứ phải cấu hình, phải nâng cấp, phải nhớ, và phải gác. Gộp được thành một thì gộp.

Còn có nên chuyển hay không thì mình nghĩ hỏi đúng ba câu là ra:

  1. Stack của bạn có thuần JS/TS/CSS không? Nếu có nhiều Markdown hoặc YAML cần format tự động thì Biome chưa lo được.
  2. Bạn có đang sống nhờ plugin ESLint đặc thù nào không? Kiểm tra xem có luật tương đương trước khi gỡ.
  3. Đội bạn có chịu được một commit đổi format toàn repo không? Cú quét đầu tiên chắc chắn to, dù nội dung thay đổi chẳng đáng bao nhiêu.

Ba câu đó mà xuôi thì cứ chạy biome migrate rồi đo thử trên một nhánh nháp. Mất một buổi thôi, mà đổi lại là bớt hẳn một công cụ khỏi đầu.