Skip to content

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=0

Trois principes qui découlent de ce modèle :

  1. 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.
  2. 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.
  3. 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 bugAttrapé parExemple
Style / cosmétiqueFormatterindentation, quotes, virgule finale
Erreur syntaxique de logiqueLinter (untyped)no-unused-vars, no-fallthrough, eqeqeq
Bug dépendant des typesLinter (typed)no-floating-promises, no-misused-promises
Incohérence de typesType-checker (tsc)Argument of type X is not assignable…
Vulnérabilité connueAudit / SCAnpm 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 :

bash
pnpm add -D --save-exact @biomejs/biome
pnpm biome init

Cela crée un biome.json :

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 :

bash
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.

bash
pnpm add -D eslint @eslint/js typescript-eslint
js
// 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 :

bash
eslint .                      # check
eslint . --fix                # autofix
eslint . --max-warnings=0     # CI strict

Les 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.

bash
pnpm add -D oxlint
bash
oxlint src/                 # lint en quelques ms
oxlint --max-warnings=0 .   # CI

Straté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 :

json
// .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 :

bash
biome migrate eslint --write
biome migrate prettier --write

Cela 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 :

  1. Installe Biome en parallèle d'ESLint (les deux tournent).
  2. Lance biome check --write . une fois, commit.
  3. Tourne les deux en CI pendant 1-2 semaines, compare les findings.
  4. Quand l'équipe est confortable, supprime ESLint/Prettier.

2. Pre-commit avec husky + lint-staged

Pour ne lint que les fichiers modifiés :

bash
pnpm add -D husky lint-staged
pnpm husky init
json
// 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"]
  }
}
bash
# .husky/pre-commit
pnpm lint-staged

Pour des projets très gros, Oxlint en pre-commit est encore mieux :

json
"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.json
bash
biome check packages/

Pour ESLint en monorepo, utilise eslint.config.mjs racine avec des overrides par chemin :

js
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

yaml
# .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 check

Le 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 :

js
// 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 :

ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 3rd party API returns any
const data = lib.parse(input) as any

Une règle CI : interdire eslint-disable sans -- (commentaire explicatif). Cela évite les disable "drive-by".

🔄 Versions — Node 18 / 20 / 22 / 24

VersionImpact linting
18Min pour ESLint v9, Biome v2, Oxlint
20Toutes les chaînes modernes
22Mêmes outils, perf++
24Mê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érer eslint-config-prettier qui 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.json legacy en 2026 : ESLint v9 ne le supporte plus par défaut. Migre vers eslint.config.mjs ou bloque-toi sur ESLint v8 (déconseillé).
  • Règles typed-checked sans projet TS configuré : parserOptions.projectService ou project est obligatoire. Sans ça, no-floating-promises et 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.
  • autofix agressif sur du code legacy : biome check --write --unsafe peut casser du code. Toujours review les diffs avant commit.
  • Configs partagées versionnées : @org/eslint-config non pinned, qui change derrière ton dos un vendredi soir. Pin la version exact ou utilise npm overrides.
  • ignores qui 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 blame dé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.ignorefiles.includes (avec négations !glob), organizeImports top-level → assist.actions.source.organizeImports. La règle noConsoleLog est dépréciée au profit de noConsole (avec option { allow: ["error", "warn"] }). Un biome.json copié d'un vieux blog échoue ou ignore silencieusement. Lance biome migrate --write pour mettre à niveau une config 1.x.
  • projectService mais tsconfig n'inclut pas le fichier linté : typescript-eslint ne peut pas typer un fichier hors du graphe tsconfig (souvent eslint.config.mjs lui-même, ou des scripts hors include). Symptôme : Parsing error: … was not found by the project service. Fix : allowDefaultProject dans parserOptions.projectService pour la poignée de fichiers concernés, ou un tsconfig dédié.
  • Pre-commit qui re-stage en silence : lint-staged applique l'autofix puis re-git add. Si un fix change la sémantique (rare avec --write safe, possible avec --unsafe), tu commites du code que tu n'as pas relu. Ne mets jamais --unsafe en pre-commit.

🧪 Testing — comment vérifier la santé de ton lint

bash
# 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".

json
// 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" } } }
    }
  ]
}
json
// 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"]
  }
}
yaml
# .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
          fi

Cette 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

OutilQuandÉviter quand
BiomeNouveau projet, vitesse prioritaire, simplicitéBesoin de plugins ESLint custom complexes
ESLint v9Codebase legacy, écosystème de plugins indispensable, règles customGreenfield où Biome suffit
OxlintPre-commit ultra-rapide en complémentComme seul linter (pas assez complet)
PrettierMaintenance d'un projet existantNouveau projet en 2026 (Biome fait mieux)
dprintCodebase 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

js
// 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)

js
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é

bash
pnpm add -D eslint-plugin-security
js
import 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 :

bash
# 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 :

bash
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.

ts
// 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 :

js
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 :

ts
// @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' } },
}
json
// projet utilisateur — biome.json
{
  "extends": ["@org/config-biome/biome.json"]
}

Pour ESLint :

js
// @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 */ } },
]
js
// 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

bash
# 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é :

json
// .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 / ignore sans 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-config non 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.

bash
# 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 ci

betterer 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/Solutionpnpm 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-commitoxlint --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/Solutionbetterer : 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

🗓️ 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.

Bibliothèque tech perso — Achref