Linting & formatting 2026 — Biome, ESLint, Oxlint, Prettier
TL;DR — Le paysage du linting JS/TS a basculé en 2025. Biome (Rust, all-in-one lint + format ~25× plus rapide qu'ESLint+Prettier) est devenu le défaut pragmatique pour les nouveaux projets. Oxlint (Rust aussi, lint-only, encore plus rapide mais subset d'ESLint) sert d'accélérateur en pre-commit. ESLint v9 (flat config) garde la couronne de l'écosystème (plugins, règles custom) et reste pertinent pour les codebases qui en dépendent. Prettier survit principalement par inertie. La règle d'or 2026 : un outil, une fois, en CI et en pre-commit, jamais la guerre des règles entre devs.
🧠 Mental model — ASCII + analogie
Trois rôles à distinguer :
┌─────────────────────────────────────────────────────────────┐
│ LINTER : trouve les bugs et code smells │
│ → no-unused-vars, no-floating-promises, prefer-const │
│ → outils : ESLint, Biome (lint), Oxlint │
├─────────────────────────────────────────────────────────────┤
│ FORMATTER : impose un style cohérent │
│ → indent, quotes, trailing commas, line breaks │
│ → outils : Prettier, Biome (format), dprint │
├─────────────────────────────────────────────────────────────┤
│ TYPE-CHECKER : vérifie les types (TS) │
│ → tsc --noEmit │
└─────────────────────────────────────────────────────────────┘L'écosystème historique séparait ces trois rôles (ESLint + Prettier + tsc). Biome unifie lint + format en un seul binaire, ce qui élimine plein de conflits (qui formatte les imports ? qui ordonne ? qui décide des trailing commas ?).
Analogie : ESLint+Prettier, c'était un duo de cuisiniers qui se marchaient parfois dessus. Biome, c'est un chef unique qui fait les deux et sait exactement comment dresser l'assiette.
Comment un staff engineer raisonne sur le linting
Le piège junior est de traiter le linting comme « un choix d'outil ». Le staff engineer le traite comme un système de feedback à plusieurs étages, chacun avec un budget de latence et un rôle distinct :
FEEDBACK LOOP LATENCE CIBLE RÔLE OUTIL
─────────────────────────────────────────────────────────────────────────────
1. Frappe au clavier < 100 ms squiggles dans l'éditeur LSP (Biome/ESLint)
2. Sauvegarde < 300 ms format + autofix safe formatOnSave
3. Pre-commit < 2 s bloque les fautes grossières Oxlint / Biome (staged)
4. Pull request (CI) < 60 s règles typed lentes + audit ESLint typed + tsc
5. Merge → main — main reste toujours vert biome ci / eslint --max-warnings=0Trois principes qui découlent de ce modèle :
- Le coût d'une règle doit matcher l'étage. Une règle typed-checked (
no-floating-promises) coûte un type-check complet : interdite au pre-commit (ferait exploser le budget 2 s), parfaite en CI. Une règle syntaxique (no-debugger) coûte un parse : parfaite au pre-commit. - Un seul outil décide du format. Deux formatters = oscillation infinie (A reformate ce que B vient de reformater). C'est non négociable : un et un seul propriétaire du style.
- Le linter ne doit jamais bloquer le flow de pensée. S'il est plus lent que la frappe, les devs le contournent (
--no-verify, désactivation de l'extension). Un linter contourné a une valeur nette de zéro. La vitesse n'est pas du confort, c'est une condition d'adoption.
Lint vs format vs type-check — qui attrape quoi
| Classe de bug | Attrapé par | Exemple |
|---|---|---|
| Style / cosmétique | Formatter | indentation, quotes, virgule finale |
| Erreur syntaxique de logique | Linter (untyped) | no-unused-vars, no-fallthrough, eqeqeq |
| Bug dépendant des types | Linter (typed) | no-floating-promises, no-misused-promises |
| Incohérence de types | Type-checker (tsc) | Argument of type X is not assignable… |
| Vulnérabilité connue | Audit / SCA | npm audit, Snyk, eslint-plugin-security |
Erreur fréquente : croire que le linter remplace tsc. Faux. Le linter complète tsc en attrapant des patterns que le système de types autorise mais qui sont presque toujours des bugs (une Promise non awaited est parfaitement typée mais quasi toujours fausse).
🛠️ Code minimal — Biome
Installation et init :
pnpm add -D --save-exact @biomejs/biome
pnpm biome initCela crée un biome.json :
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": { "noBannedTypes": "error" },
"suspicious": { "noExplicitAny": "warn", "noConsole": { "level": "warn", "options": { "allow": ["error", "warn"] } } },
"style": { "useImportType": "error", "useNodejsImportProtocol": "error" }
}
},
"javascript": {
"formatter": { "quoteStyle": "single", "trailingCommas": "all", "semicolons": "asNeeded" }
}
}Commandes courantes :
biome check . # lint + format (read-only)
biome check --write . # applique fixes safe
biome check --write --unsafe . # applique aussi les fixes "unsafe"
biome format --write .
biome lint .
biome ci . # mode CI : zero warning policy🛠️ Code minimal — ESLint v9 (flat config)
ESLint v9 a abandonné .eslintrc.json au profit de eslint.config.mjs (flat config), une seule source de vérité, configurable en pur JavaScript.
pnpm add -D eslint @eslint/js typescript-eslint// eslint.config.mjs
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import nodePlugin from 'eslint-plugin-n'
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true, // v8+ : auto-discover tsconfig
tsconfigRootDir: import.meta.dirname,
},
},
plugins: { n: nodePlugin },
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'n/no-process-exit': 'error',
'no-console': ['warn', { allow: ['error', 'warn'] }],
},
},
{ ignores: ['dist/**', 'coverage/**', '**/*.d.ts'] },
)Commandes :
eslint . # check
eslint . --fix # autofix
eslint . --max-warnings=0 # CI strictLes règles typed-checked (qui utilisent les types TS) coûtent cher mais détectent des bugs invisibles autrement : no-floating-promises, no-misused-promises, no-unsafe-assignment.
🛠️ Code minimal — Oxlint en pre-commit
Oxlint est lint-only, en Rust, et couvre ~75% des règles ESLint courantes. 50-100× plus rapide. Idéal pour un feedback ultra-rapide en pre-commit.
pnpm add -D oxlintoxlint src/ # lint en quelques ms
oxlint --max-warnings=0 . # CIStratégie hybride : Oxlint en pre-commit (instantané), ESLint avec règles typed en CI (complet mais lent). Tu paies seulement le coût lent sur les PRs.
🛠️ Prettier — quand il survit encore
Prettier reste pertinent pour les codebases historiques. Configuration minimale :
// .prettierrc.json
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"arrowParens": "always",
"plugins": ["prettier-plugin-organize-imports"]
}Pour éviter les conflits avec ESLint : utilise eslint-config-prettier (désactive les règles de style ESLint qui entrent en conflit), pas eslint-plugin-prettier (lent, bruit).
🎯 Patterns courants
1. Migration ESLint + Prettier → Biome
Biome fournit un assistant de migration :
biome migrate eslint --write
biome migrate prettier --writeCela importe le maximum de règles de tes configs existantes. Tu finis avec un biome.json à 80% prêt, à ajuster manuellement pour les 20% restants (règles ESLint sans équivalent Biome).
Stratégie pragmatique de migration :
- Installe Biome en parallèle d'ESLint (les deux tournent).
- Lance
biome check --write .une fois, commit. - Tourne les deux en CI pendant 1-2 semaines, compare les findings.
- Quand l'équipe est confortable, supprime ESLint/Prettier.
2. Pre-commit avec husky + lint-staged
Pour ne lint que les fichiers modifiés :
pnpm add -D husky lint-staged
pnpm husky init// package.json
{
"scripts": { "prepare": "husky" },
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["biome check --write --no-errors-on-unmatched"],
"*.{json,md,yml}": ["biome format --write --no-errors-on-unmatched"]
}
}# .husky/pre-commit
pnpm lint-stagedPour des projets très gros, Oxlint en pre-commit est encore mieux :
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["oxlint", "biome format --write"]
}3. Monorepo — config partagée
Avec pnpm workspaces, on partage une config Biome racine :
repo/
├── biome.json ← config racine
├── packages/
│ ├── api/
│ ├── core/
│ └── web/
└── package.jsonbiome check packages/Pour ESLint en monorepo, utilise eslint.config.mjs racine avec des overrides par chemin :
export default tseslint.config(
// base globale
{
files: ['packages/**/*.ts'],
rules: { /* ... */ },
},
// override pour le package web (React)
{
files: ['packages/web/**/*.tsx'],
plugins: { react: reactPlugin },
rules: { 'react/jsx-key': 'error' },
},
)4. CI integration — fail fast
# .github/workflows/lint.yml
name: lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm biome ci . # strict mode
- run: pnpm tsc --noEmit # type checkLe biome ci est important : il traite warnings comme errors et n'autorise pas de fixes. C'est le rôle de la CI : valider, pas corriger.
5. Règles custom pour la codebase
Quand une règle métier doit être imposée (ex : "tout handler HTTP doit logger"), tu écris un plugin ESLint custom :
// tools/eslint-plugin-internal/require-logger.js
export default {
meta: { type: 'problem' },
create(ctx) {
return {
'FunctionDeclaration[id.name=/^handle.*/]'(node) {
const hasLog = JSON.stringify(node.body).includes('logger.')
if (!hasLog) ctx.report({ node, message: 'handler must use logger' })
},
}
},
}Biome n'a pas (encore) d'API plugin équivalent ; c'est un cas où ESLint reste indispensable.
6. Désactivation locale — règles d'engagement
Désactiver une règle inline est parfois nécessaire mais ça doit toujours s'accompagner d'un commentaire justifiant :
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 3rd party API returns any
const data = lib.parse(input) as anyUne règle CI : interdire eslint-disable sans -- (commentaire explicatif). Cela évite les disable "drive-by".
🔄 Versions — Node 18 / 20 / 22 / 24
| Version | Impact linting |
|---|---|
| 18 | Min pour ESLint v9, Biome v2, Oxlint |
| 20 | Toutes les chaînes modernes |
| 22 | Mêmes outils, perf++ |
| 24 | Mêmes outils, V8 latest |
Côté outils, viser en 2026 : Biome 2.x, ESLint 9.x, typescript-eslint 8.x, Prettier 3.x (si encore utilisé), Oxlint 0.x mais à surveiller.
⚠️ Pitfalls
- Double formattage Prettier + ESLint : avec
eslint-plugin-prettier, Prettier tourne dans ESLint. C'est lent et bruyant. Préférereslint-config-prettierqui se contente de désactiver les règles de style ESLint conflictuelles. - Biome + Prettier sur le même fichier : conflit garanti. Choisis un et désactive l'autre dans la config IDE.
.eslintrc.jsonlegacy en 2026 : ESLint v9 ne le supporte plus par défaut. Migre verseslint.config.mjsou bloque-toi sur ESLint v8 (déconseillé).- Règles typed-checked sans projet TS configuré :
parserOptions.projectServiceouprojectest obligatoire. Sans ça,no-floating-promiseset amis ne marchent pas. - Linter en watch dans le terminal et l'IDE : double exécution, CPU à fond. Désactive l'extension VSCode si tu lances
biome lint --watch. - Coverage des règles Oxlint : il couvre ~75% d'ESLint, mais pas tout. Si tu pré-commit avec Oxlint seul, des règles passent en CI. Garde un job CI avec ESLint complet.
autofixagressif sur du code legacy :biome check --write --unsafepeut casser du code. Toujours review les diffs avant commit.- Configs partagées versionnées :
@org/eslint-confignon pinned, qui change derrière ton dos un vendredi soir. Pin la version exact ou utilisenpm overrides. ignoresqui ne marchent pas en flat config : ils doivent être dans un objet séparé ({ ignores: [...] }), pas mélangés avec des règles. Sinon ils sont ignorés silencieusement.- Formatter qui change tous les fichiers au premier run :
git blamedétruit. Solution : un commit unique "chore: format codebase" et ajoute-le à.git-blame-ignore-revs. - API Biome 1.x copiée-collée en 2.0 : plusieurs flags et clés ont été renommés au passage 2.0.
--apply→--write,--apply-unsafe→--write --unsafe,files.include/files.ignore→files.includes(avec négations!glob),organizeImportstop-level →assist.actions.source.organizeImports. La règlenoConsoleLogest dépréciée au profit denoConsole(avec option{ allow: ["error", "warn"] }). Unbiome.jsoncopié d'un vieux blog échoue ou ignore silencieusement. Lancebiome migrate --writepour mettre à niveau une config 1.x. projectServicemaistsconfign'inclut pas le fichier linté : typescript-eslint ne peut pas typer un fichier hors du graphetsconfig(souventeslint.config.mjslui-même, ou des scripts horsinclude). Symptôme :Parsing error: … was not found by the project service. Fix :allowDefaultProjectdansparserOptions.projectServicepour la poignée de fichiers concernés, ou un tsconfig dédié.- Pre-commit qui re-stage en silence :
lint-stagedapplique l'autofix puis re-git add. Si un fix change la sémantique (rare avec--writesafe, possible avec--unsafe), tu commites du code que tu n'as pas relu. Ne mets jamais--unsafeen pre-commit.
🧪 Testing — comment vérifier la santé de ton lint
# 1. Diff entre deux runs : zéro changement sur main
biome check . && git diff --exit-code
# 2. Couvre tous les fichiers attendus
biome check --reporter=summary .
# 3. Temps acceptable (< 10s sur projet moyen)
time biome check .
# 4. CI rouge si on désactive sans raison
grep -rE "(eslint-disable|biome-ignore)[^-]" src/ && exit 1 || true🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique "LexFidens" monorepo Biome end-to-end
LexFidens maintient un monorepo pnpm avec 8 apps (signature, dashboard, mobile React Native, app interne ops, etc.) et 12 packages partagés (UI design system, sdk client, fixtures de test, etc.). Avant 2025, ils utilisaient ESLint + Prettier avec une config partagée. Le lint complet prenait 45 secondes en CI, et les pre-commit hooks Husky tournaient à 12 secondes — assez lent pour que les devs les contournent avec --no-verify.
Migration à Biome en mai 2025 : un seul fichier biome.json à la racine du monorepo, qui couvre lint + format + import sort + unsafe-fix. Le lint complet est passé de 45 secondes à 1.2 seconde sur les mêmes 350k lignes de code (Biome est écrit en Rust, parser ultra-rapide). Pre-commit à 0.3 seconde — plus aucun dev ne le contourne. L'équipe a aussi pu retirer 7 packages (eslint, prettier, eslint-plugin-import, eslint-plugin-react, eslint-config-prettier, etc.), simplifiant le package.json et l'install. Tradeoff : ils ont perdu quelques règles ESLint custom liées à leur DSL interne, qu'ils ont reportées en scripts de check séparés (5 règles, ~200 lignes). Bilan : DX considérablement amélioré, CI 7 minutes plus rapide.
Scénario 2 — E-commerce "ModeCircuit" migration ESLint → Biome progressive
ModeCircuit a un codebase Node + React de ~120k lignes, ESLint depuis 5 ans avec une config complexe (40+ règles custom, 12 plugins, dépendances cycliques entre plugins). Migrer big-bang vers Biome aurait pris 2 sprints et cassé énormément de PR en cours. L'équipe a opté pour une migration progressive sur 3 mois.
Phase 1 (semaine 1-2) : biome migrate eslint qui convertit automatiquement 80 % des règles. Audit des 20 % restantes, certaines ont un équivalent Biome (renommé), d'autres pas (reportées en TODO). Phase 2 (mois 1) : Biome devient le formateur principal (biome format --write), ESLint reste pour les règles non-converties. Les deux tournent en CI mais Biome est obligatoire (bloquant), ESLint en warning. Phase 3 (mois 2-3) : conversion des règles custom ESLint en scripts standalone (scripts/lint-custom.ts), retrait progressif d'ESLint. Au final, ESLint a complètement disparu en septembre 2025. Bénéfice : -85 % de temps de lint, -3 packages dans le monorepo, et les devs adorent la rapidité.
Scénario 3 — SaaS RH "PaySimple" règles custom internes
PaySimple a des conventions très spécifiques liées au domaine RH (manipulations de salaires, données employés). Exemples : "interdire JSON.stringify(employee) parce que ça expose des données RGPD sensibles", "interdire Math.round(money) parce qu'on doit utiliser Money.round() qui gère les arrondis bancaires", "imposer un commentaire // RGPD-OK: <raison> sur tout accès à employee.dateOfBirth".
Biome ne supporte pas (encore) les règles custom au niveau d'ESLint custom rules. PaySimple a donc une stack hybride : Biome pour 95 % du lint (vitesse, simplicité), et ESLint résiduel avec uniquement les 8 règles custom internes (~600 lignes de code dans @paysimple/eslint-rules). Les deux tournent en parallèle dans la CI, Biome en 2 secondes et ESLint en 8 secondes. L'équipe surveille de près l'évolution de Biome (notamment le RFC sur les custom rules) pour potentiellement consolider sur un seul outil. En attendant, le pattern hybride leur donne le meilleur des deux mondes : la vitesse Biome + la flexibilité ESLint pour leurs invariants métier.
🛠️ Exemple end-to-end
Cas d'usage : "config monorepo LexFidens — biome.json à la racine avec overrides par sous-package, hook pre-commit Husky + lint-staged, CI GitHub Actions avec cache, et règle Biome activée pour interdire les imports cycliques + console.log".
// biome.json — racine du monorepo
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": ["**/*.{ts,tsx,js,jsx,json}", "!**/dist/**", "!**/build/**", "!**/.next/**", "!**/coverage/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn"
},
"suspicious": {
"noConsole": { "level": "error", "options": { "allow": ["error", "warn"] } },
"noExplicitAny": "error",
"noEmptyBlockStatements": "error"
},
"style": {
"useNamingConvention": {
"level": "error",
"options": { "strictCase": false, "conventions": [
{ "selector": { "kind": "interface" }, "formats": ["PascalCase"] },
{ "selector": { "kind": "typeAlias" }, "formats": ["PascalCase"] },
{ "selector": { "kind": "enum" }, "formats": ["PascalCase"] }
] }
},
"useImportType": "error",
"useTemplate": "error"
},
"complexity": {
"noUselessCatch": "error",
"noBannedTypes": "error",
"useLiteralKeys": "error"
},
"performance": { "noAccumulatingSpread": "warn" },
"security": { "noDangerouslySetInnerHtml": "error" }
}
},
"javascript": {
"formatter": { "quoteStyle": "single", "semicolons": "asNeeded", "trailingCommas": "all" }
},
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "on" } }
},
"overrides": [
{
"includes": ["apps/mobile/**"],
"linter": { "rules": { "style": { "useImportType": "off" } } }
},
{
"includes": ["**/*.test.ts", "**/*.spec.ts", "**/fixtures/**"],
"linter": { "rules": { "suspicious": { "noExplicitAny": "off", "noConsole": "off" } } }
}
]
}// package.json — extrait
{
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write --unsafe .",
"format": "biome format --write .",
"prepare": "husky"
},
"devDependencies": {
"@biomejs/biome": "2.0.0",
"husky": "^9.0.0",
"lint-staged": "^16.0.0"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,json}": ["biome check --write --no-errors-on-unmatched"]
}
}# .github/workflows/ci.yml — job lint
name: ci
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Biome check
run: pnpm lint
- name: No-skip check
run: |
if grep -rE "biome-ignore[^-]" src/ apps/ packages/; then
echo "found biome-ignore without explanation"
exit 1
fiCette config combine sept patterns Biome seniors : (1) useIgnoreFile: true qui réutilise .gitignore, (2) règles recommended étendues avec des règles strict (noConsole, noExplicitAny en error), (3) useNamingConvention pour les types/interfaces/enums (PascalCase forcé), (4) useImportType qui transforme import { User } en import type { User } quand User est uniquement un type (réduit le bundle), (5) overrides par sous-dossier (les tests autorisent console.log et any, le mobile a un override pour useImportType), (6) integration lint-staged qui ne lint que les fichiers modifiés au pre-commit (rapide), (7) CI qui fail si quelqu'un met // biome-ignore sans explication (force la documentation des dérogations). Sur le monorepo LexFidens (350k lignes), pnpm lint tourne en 1.2 seconde, vs 45 secondes avec l'ancien setup ESLint + Prettier.
🔁 Quand utiliser / éviter
| Outil | Quand | Éviter quand |
|---|---|---|
| Biome | Nouveau projet, vitesse prioritaire, simplicité | Besoin de plugins ESLint custom complexes |
| ESLint v9 | Codebase legacy, écosystème de plugins indispensable, règles custom | Greenfield où Biome suffit |
| Oxlint | Pre-commit ultra-rapide en complément | Comme seul linter (pas assez complet) |
| Prettier | Maintenance d'un projet existant | Nouveau projet en 2026 (Biome fait mieux) |
| dprint | Codebase polyglotte (Rust, Go, etc.) | Pur Node (Biome plus simple) |
🛠️ Règles essentielles à activer
Au-delà des recommended, certaines règles font une vraie différence en prod Node.
typescript-eslint typed rules
// eslint.config.mjs
rules: {
// Async/await safety
'@typescript-eslint/no-floating-promises': 'error', // tu await tout ce qui retourne Promise
'@typescript-eslint/no-misused-promises': 'error', // pas de Promise dans un if, un setTimeout, etc.
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/require-await': 'warn',
// Type safety
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
// Code quality
'@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'inline-type-imports' }],
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/strict-boolean-expressions': ['warn', { allowNullableBoolean: true }],
}no-floating-promises à lui seul prévient des dizaines de bugs par an dans une grosse codebase. Une Promise qui n'est ni await ni .catch() peut crash le process ou avaler une erreur silencieusement.
Plugin eslint-plugin-n (Node-spécifique)
import nodePlugin from 'eslint-plugin-n'
export default [
nodePlugin.configs['flat/recommended'],
{
rules: {
'n/no-process-exit': 'error', // bannit process.exit() — laisse le shutdown propre
'n/no-deprecated-api': 'error',
'n/no-missing-import': 'error', // imports cassés
'n/no-extraneous-import': 'error', // imports non déclarés (phantom deps)
'n/no-unpublished-import': 'error', // pas d'import devDeps en prod code
'n/hashbang': 'error',
},
},
]Plugin sécurité
pnpm add -D eslint-plugin-securityimport security from 'eslint-plugin-security'
export default [
security.configs.recommended,
// Active : detect-eval-with-expression, detect-non-literal-fs-filename,
// detect-object-injection, etc.
]🛠️ Performance — linter rapide en gros monorepo
Pour des codebases > 500 fichiers TS, ESLint peut prendre minutes. Astuces :
# 1. Cache ESLint
eslint --cache --cache-location .eslintcache .
# 2. Lint uniquement les changements (git)
eslint $(git diff --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx|js)$')
# 3. Parallèle (sur multi-core)
eslint --concurrency=auto . # ESLint v9+
# 4. typescript-eslint en mode projectService (v8+)
# parserOptions.projectService: true (auto-discovers tsconfig, plus rapide)Sur des projets vraiment gros (> 5000 fichiers), Biome ou Oxlint deviennent quasi obligatoires :
time biome check . # 2s
time oxlint . # 0.3s
time eslint . # 90s🛠️ Formatage des imports — un débat clos
L'ordre et le groupement des imports était une source de PR drama. Tous les outils modernes le gèrent automatiquement.
// Auto-organisé par Biome / Prettier / ESLint avec plugin
import { writeFile } from 'node:fs/promises' // 1. Node builtins
import { z } from 'zod' // 2. External deps
import type { Logger } from 'pino'
import { db } from '@/db' // 3. Aliases
import { logger } from '@/logger'
import { createOrder } from './order.service.ts' // 4. Relatifs
import type { Order } from './types.ts'Biome trie par défaut. ESLint via eslint-plugin-import :
import importPlugin from 'eslint-plugin-import'
export default [
{
plugins: { import: importPlugin },
rules: {
'import/order': ['error', {
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
}],
'import/no-duplicates': 'error',
'import/no-cycle': 'error', // détection de cycles
'import/no-self-import': 'error',
},
},
]import/no-cycle est précieux : les cycles d'imports en ESM peuvent causer des undefined au runtime, très difficiles à debug.
🛠️ Configuration partagée pour équipes
Si tu maintiens plusieurs projets, factorise la config en package interne :
// @org/config-biome/index.ts
export const biomeConfig = {
$schema: 'https://biomejs.dev/schemas/2.0.0/schema.json',
formatter: { indentStyle: 'space', indentWidth: 2, lineWidth: 100 },
linter: { enabled: true, rules: { recommended: true } },
javascript: { formatter: { quoteStyle: 'single', semicolons: 'asNeeded' } },
}// projet utilisateur — biome.json
{
"extends": ["@org/config-biome/biome.json"]
}Pour ESLint :
// @org/eslint-config/index.js
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
{ rules: { /* règles partagées */ } },
]// projet utilisateur — eslint.config.mjs
import baseConfig from '@org/eslint-config'
export default [
...baseConfig,
{ rules: { /* overrides spécifiques */ } },
]🛠️ Comment auditer rapidement la santé du lint
# Combien de disable comments ?
grep -rE "(eslint-disable|biome-ignore)" src/ | wc -l
# Combien de any ?
grep -rE ":\s*any\b|\bas\s+any\b" src/ | wc -l
# Combien de TODO/FIXME ?
grep -rE "TODO|FIXME|XXX|HACK" src/ | wc -l
# Combien de console.log ?
grep -rE "console\.(log|debug)" src/ | wc -l
# Cycle d'imports ?
npx madge --circular src/Ces métriques, suivies dans le temps, donnent une idée de la dette technique sans rentrer dans le débat philosophique.
🛠️ Editor integration
Tous ces outils ont des extensions VSCode officielles. Setup recommandé :
// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": { "editor.defaultFormatter": "biomejs.biome" },
"[json]": { "editor.defaultFormatter": "biomejs.biome" },
"biome.lspBin": "./node_modules/@biomejs/biome/bin/biome",
"eslint.experimental.useFlatConfig": true,
"eslint.runtime": "node"
}Important : versionne ce fichier, sinon chaque dev configure différemment et les diffs sont chaotiques.
📈 Observabilité & gouvernance du lint à l'échelle
Sur une codebase à 50+ devs, le linting devient un problème de gouvernance, pas d'outillage. Ce que les équipes seniors mesurent :
- Temps de lint CI (p50/p95) : si le p95 dépasse 60 s, les devs attendent ou bypassent. Trackez-le comme une SLO de DX.
- Taux de
disable/ignoresans justification : prolifération = règle mal calibrée ou trop agressive. Une règle désactivée 200 fois n'est pas une règle, c'est du bruit — soit on la corrige partout, soit on la retire. - Nouveaux warnings par PR : un compteur qui ne fait que monter = dette qui s'accumule. Pattern « ratchet » ci-dessous.
- Drift de version des configs partagées :
@org/eslint-confignon pinné qui change le comportement sous les pieds des équipes.
Le pattern « ratchet » (cliquet) pour introduire une règle sur du legacy
On ne peut pas activer no-explicit-any: error d'un coup sur 5000 any existants. La stratégie staff : figer la dette, interdire la régression.
# 1. Génère une baseline des violations existantes
eslint --format json . > .eslint-baseline.json
# 2. En CI : la règle est en error, mais on tolère UNIQUEMENT les violations
# déjà présentes dans la baseline. Toute NOUVELLE violation fait échouer la CI.
# Outils : eslint-nibble, betterer, ou un script maison qui diffe les counts.
npx betterer cibetterer est l'outil canonique : il stocke un snapshot des violations et fait échouer la CI si le nombre augmente — le compteur ne peut que descendre. Tu actives la règle en error sans bloquer les 5000 cas existants, et chaque PR doit en résorber un peu (ou au moins ne pas en ajouter). C'est la seule façon réaliste de migrer une grosse codebase vers des règles strictes sans geler le delivery.
Sécurité — le lint comme première ligne (et ses limites)
Le linting attrape des patterns dangereux, pas des vulnérabilités prouvées. eslint-plugin-security signale child_process.exec(userInput) ou eval(x) — utile, mais bruyant (beaucoup de faux positifs sur detect-object-injection). À combiner, jamais à confondre, avec :
npm audit/pnpm audit(CVE des dépendances),- un SCA (Snyk, Dependabot, Socket) pour les supply-chain attacks,
- du SAST (CodeQL, Semgrep) pour les flux de données inter-fichiers que le linter ne voit pas (un linter raisonne fichier par fichier ; un cycle taint source→sink à travers 3 modules lui échappe).
Mental model : le linter est un garde-fou local et syntaxique, pas un analyseur de sécurité. Le présenter comme tel en revue d'archi est une erreur classique.
🏋️ Exercices
Progressifs. Chacun a un Objectif clair et un Indice/Solution. Fais-les dans l'ordre.
1. Bootstrap zéro-config → config opinionnée (implémenter)
Objectif — Partir d'un projet TS vierge et obtenir un biome check . qui tourne en CI avec quotes simples, pas de point-virgule, noConsole en error sauf error/warn, et organize-imports actif.
Indice/Solution — pnpm add -D --save-exact @biomejs/biome && pnpm biome init. Édite biome.json : javascript.formatter.quoteStyle: "single", semicolons: "asNeeded" ; sous linter.rules.suspicious, "noConsole": { "level": "error", "options": { "allow": ["error", "warn"] } } ; active assist.actions.source.organizeImports: "on". Vérifie avec biome check . && git diff --exit-code (doit être idempotent).
2. Activer no-floating-promises sur du code async (implémenter + comprendre)
Objectif — Configurer typescript-eslint en mode typed (projectService), activer no-floating-promises, et faire échouer le lint sur un void-oubli réel (sendEmail(user) sans await).
Indice/Solution — Le biome seul ne suffit pas : no-floating-promises est typed → ESLint. parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }. Écris une fonction async function notify() { sendEmail(user) } et observe l'erreur. Les deux fixes légitimes : await sendEmail(user) (séquentiel) ou void sendEmail(user) explicite + .catch() (fire-and-forget assumé). Mesure le delta de temps eslint . avec vs sans projectService.
3. Pipeline à deux étages Oxlint + ESLint (production-grade)
Objectif — Pre-commit Oxlint (< 1 s, instantané) + CI ESLint typed complet. La CI doit attraper une règle typed que le pre-commit laisse passer — et tu dois le prouver par un cas qui passe le hook mais casse la CI.
Indice/Solution — .husky/pre-commit → oxlint --max-warnings=0 $(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx)$'). CI → eslint . --max-warnings=0 avec règles typed. Commite un no-misused-promises (ex : if (asyncFn())) : Oxlint (untyped) le laisse passer, ESLint typed le rejette. C'est exactement le tradeoff vitesse/exhaustivité du modèle à étages — documente-le dans le README.
4. Migration ratchet d'une règle strict sur du legacy (production-grade)
Objectif — Sur un repo avec ~50 any, activer no-explicit-any: error sans bloquer la CI sur l'existant, mais en interdisant tout nouveau any.
Indice/Solution — betterer : betterer init, ajoute un test typescript('no any', '@typescript-eslint/no-explicit-any') ou un test eslint custom, commit le .betterer.results. En CI : betterer ci. Ajoute un any neuf dans une branche → CI rouge ; supprime un any existant → baseline descend. Vérifie que la baseline ne peut jamais remonter (c'est le cœur du cliquet).
5. Casser puis réparer : la guerre des formatters (break-then-fix)
Objectif — Reproduire l'oscillation Biome ↔ Prettier sur un même fichier, observer le diff qui ping-pong, puis établir un propriétaire unique du format.
Indice/Solution — Configure Biome et Prettier avec des trailingComma/semi divergents, lance biome format --write puis prettier --write en boucle → le fichier change à chaque passe. Fix : choisis un seul formatter, ajoute eslint-config-prettier si tu gardes ESLint pour le lint, et dans .vscode/settings.json force un unique editor.defaultFormatter. Re-vérifie l'idempotence : format && format && git diff --exit-code.
6. Règle métier custom + garde anti-disable (break-then-fix, expert)
Objectif — Écrire une règle ESLint custom « tout accès à employee.ssn exige un commentaire // RGPD-OK: au-dessus », puis empêcher qu'on la contourne par un eslint-disable nu.
Indice/Solution — Plugin custom avec un visitor MemberExpression[property.name='ssn'] qui inspecte les sourceCode.getCommentsBefore(node). Pour le garde anti-contournement : ajoute eslint-comments/require-description (de @eslint-community/eslint-plugin-eslint-comments) en error, qui force un -- explicatif sur chaque eslint-disable. Teste : un // eslint-disable-next-line sans description doit lui-même faire échouer le lint. Bonus : un check CI grep -rE "eslint-disable[^-]" en filet de sécurité ultime.
🎤 En entretien
Q : Pourquoi avoir un linter quand on a déjà TypeScript en mode strict ? Parce qu'ils attrapent des classes de bugs disjointes : tsc vérifie la cohérence des types, le linter attrape des patterns valides au regard des types mais quasi toujours faux — une Promise non awaited, un == au lieu de ===, un case sans break. Le linter encode des conventions et des bonnes pratiques que le système de types n'exprime pas.
Q : Biome est 25× plus rapide qu'ESLint. Pourquoi des équipes gardent ESLint ? Pour l'écosystème : règles custom métier, plugins (React, import/no-cycle, security, accessibilité), et surtout l'API de plugins que Biome n'a pas encore au même niveau. Le pattern senior fréquent est hybride : Biome pour 95 % (format + règles standard, ultra-rapide) et un ESLint résiduel ne portant que les quelques règles custom impossibles ailleurs.
Q : Comment introduire une règle strict sur une vieille codebase avec 5000 violations sans bloquer le delivery ? Pattern « ratchet » : on active la règle en error mais on fige une baseline des violations existantes (avec betterer ou un script qui diffe les counts). La CI échoue seulement si le nombre augmente ; il ne peut que descendre. On migre progressivement, PR par PR, sans big-bang ni gel des features.
Q : Quel étage du pipeline pour quelle règle, et pourquoi ? On aligne le coût de la règle sur le budget de latence de l'étage. Règles syntaxiques (parse uniquement) → pre-commit, budget ~2 s. Règles typed-checked (type-check complet) → CI, budget ~60 s. Mettre une règle typed en pre-commit fait exploser le budget et pousse les devs au --no-verify ; un linter contourné vaut zéro. La vitesse est une condition d'adoption, pas du confort.
🔗 Liens
- Biome : https://biomejs.dev/
- ESLint flat config : https://eslint.org/docs/latest/use/configure/configuration-files
- typescript-eslint : https://typescript-eslint.io/
- Oxlint : https://oxc.rs/docs/guide/usage/linter.html
- Prettier : https://prettier.io/
- eslint-plugin-n : https://github.com/eslint-community/eslint-plugin-n
- eslint-plugin-security : https://github.com/eslint-community/eslint-plugin-security
🗓️ Récap final
En 2026, le débat lint/format est tranché : Biome pour les nouveaux projets (un binaire, ultra-rapide, config unifiée), ESLint v9 pour les codebases existantes (écosystème plugin imbattable), Oxlint en pre-commit pour le feedback instantané. Prettier survit par inertie. La discipline qui compte : un outil, exécuté en pre-commit et en CI, avec une politique zéro warning. Tout le reste — choix exact des règles, débats sur les trailing commas — est secondaire face à la consistance.