Skip to contentrostand.dev
Blog

Passer d'ESLint et de Prettier à Oxlint et Oxfmt

·12 min de lecture
oxcoxlintoxfmteslintdevtools

Sur mon projet ComptaOpen, un passage complet d'ESLint prenait plus d'une minute en local, et surtout il consommait énormément de RAM. Le genre d'étape qu'on finit par ne plus lancer qu'en CI, où il prenait encore 42 secondes.

J'ai même pris l'habitude, en local, de "linter" uniquement les fichiers modifiés, et le verdict sur l'ensemble du projet ne tombait qu'au moment du push avec les GitHub Actions.

Je sais, j'abuse 😒😂🤣

Côté formatage, aucun style commun : sur près de 1 200 fichiers TypeScript, 559 importaient entre guillemets doubles, 525 entre guillemets simples, etc.

J'ai réglé les deux le même soir avec deux outils de la suite Oxc :

  • Oxlint à la place d'ESLint,
  • Oxfmt comme formateur.

Le lint complet tient maintenant en 2 secondes.

Voyons tout ça un peu plus en détail.


Oxc en deux mots

Oxc, pour JavaScript Oxidation Compiler, est une suite d'outils JavaScript et TypeScript écrits en Rust : parser, linter, formateur, transformer, minifier, resolver. Le projet est porté par VoidZero, l'équipe derrière Vite, et fait déjà tourner Rolldown.

Deux de ces outils remplacent des briques que tout projet JS connaît :

  • Oxlint, le linter, stable depuis sa version 1.0. Plus de 870 règles en natif : celles d'ESLint, de typescript-eslint, de React et des hooks, de jsx-a11y, d'import, de Next.js, et d'autres. Le projet annonce un linter 50 à 100 fois plus rapide qu'ESLint.
  • Oxfmt, le formateur, en bêta (versions 0.x). Il prend la sortie de Prettier comme référence et passe 100 % de ses tests de conformité JavaScript et TypeScript. Le tri des classes Tailwind et le tri des imports sont intégrés, sans plugin. Le projet annonce environ 30 fois la vitesse de Prettier.

Les deux s'installent comme n'importe quel paquet npm. Rien ne change dans le reste de la chaîne : Next.js, Turbopack et Vitest restent en place.


Avant de migrer : des sondes

Qu'Oxlint soit plus rapide, ce n'était pas la question. La question, c'était : est-ce qu'il attrape encore ce qu'ESLint attrape pour moi ?

Avant de toucher au package.json, je devais m'assurer qu'Oxlint attrapait la totalité des erreurs et avertissements que me remontait ESLint de base. J'ai donc écrit trois fichiers jetables remplis de violations volontaires, une par règle qui compte pour le projet. Puis j'ai lancé les deux linters dessus, Oxlint via pnpm dlx avec la config générée par l'outil de migration officiel, et j'ai comparé.

Violation volontaireESLintOxlint, config migrée
setState direct dans un effetattrapéattrapé
Date.now() pendant le renduattrapéattrapé
ref.current lu pendant le renduattrapéattrapé
Hook dans un ifattrapéattrapé
Dépendance d'effet manquanteattrapéattrapé
<img> brut sans alt (deux règles)attrapéattrapé
Variable inutiliséeattrapéattrapé
Dépendance à Next.js ou React dans un paquet portable (deux sondes)attrapéattrapé
Import profond par alias de chemin (@/utils/cn)attrapérien
Import relatif profond à contresens des dépendances (../bj/paie/compute)attrapérien

ESLint : 12 sur 12. Oxlint : 10 sur 12.

Les trois premières lignes sont les règles issues du React Compiler (set-state-in-effect, purity, refs), les plus récentes de eslint-plugin-react-hooks. Oxlint les fait tourner en natif, sans plugin JS. C'est ce qui m'a convaincu de continuer. Restaient donc les deux trous en bas du tableau.


Le piège : * ne descend pas dans les sous-dossiers

Les deux trous ont la même cause : un * dans les motifs de no-restricted-imports.
J'avais ici pour objectif d'interdire l'utilisation de l'alias de chemin @/ dans un dossier prévu pour être et rester portable.

eslint.config.mjs | Configuration ESLint
{
	group: ["@/*", "@@/*"],
	message: "src/lib/{core,bj}/ must stay portable: no project alias.",
},
  • Chez ESLint, le motif s'applique de façon récursive : @/* correspond à @/utils, et tout ce qui se trouve sous @/utils est bloqué avec lui, quelle que soit la profondeur (@/utils/cn, @/utils/format/date).

  • Oxlint lit le même motif comme un glob, sans récursion : * couvre un seul niveau et s'arrête au premier /. Il bloque donc @/utils, mais laisse passer @/utils/cn ou @/components/ui/button, et tout import plus profond. Bref, la règle ne bloque presque plus rien.

Même motif @/*, deux lectures : ESLint bloque tout le sous-arbre, Oxlint un seul niveau

L'outil de migration recopie les motifs tels quels, sans avertissement. Le CI reste vert, puisque le code existant respecte déjà la règle. Tant que personne n'écrit l'import interdit, une règle qui ne bloque plus rien ne se distingue pas d'une règle qui fonctionne.

La correction : /** à la place de /*, dans chaque motif.

{
  "group": ["@/**", "@@/**"],
  "message": "src/lib/{core,bj}/ must stay portable: no project alias.",
}

Avec ces motifs, les sondes donnent 12 sur 12.

Si ta config ESLint utilise * dans no-restricted-imports, repasse donc chaque motif après la migration et teste-le avec une sonde : un import profond volontaire qu'Oxlint doit bloquer.


La migration

L'outil officiel, @oxlint/migrate, lit une config ESLint au format flat et écrit un .oxlintrc.json :

pnpm dlx @oxlint/migrate eslint.config.mjs

Sur ComptaOpen : 80 règles converties, 7 écartées, aucune dont j'avais besoin. L'outil prévient aussi que le parser d'eslint-config-next est remplacé par celui d'Oxlint, et que certains settings d'ESLint sont ignorés.

Le fichier généré fonctionne, mais il est verbeux. Il recopie un par un les globals du navigateur, là où "env": { "browser": true } suffit, et il liste toutes les règles que la config d'origine se contentait de désactiver. Je l'ai réécrit à la main sur cette base, puis j'ai corrigé les motifs.

Ensuite, le remplacement :

pnpm add -D oxlint
pnpm remove eslint eslint-config-next @eslint/eslintrc
rm eslint.config.mjs
package.json
"scripts": {
  "lint": "oxlint"
}

L'étape Lint du CI appelle toujours pnpm lint, elle n'a pas bougé. Le lockfile, lui, perd 270 paquets.

Restent les commentaires de désactivation. Oxlint comprend les eslint-disable, mais les noms de règles changent : react-hooks/set-state-in-effect devient react/set-state-in-effect, @next/next/no-img-element devient nextjs/no-img-element. L'option --replace-eslint-comments de l'outil de migration s'en charge. J'ai tout converti en oxlint-disable avec les noms Oxlint, et supprimé ceux qui ne masquaient plus rien.

Lis la documentation : https://oxc.rs/docs/guide/usage/linter


Oxfmt : un seul style pour tout le projet

Oxfmt reprend les options de Prettier et produit la même sortie, à quelques lignes près, mais ce n'est pas un Prettier réécrit de bout en bout. JavaScript, TypeScript, JSON, CSS, YAML, TOML, GraphQL et Markdown passent par son moteur natif, en Rust. HTML, Vue, Svelte, Astro et MDX sont délégués à un Prettier embarqué dans le paquet npm, qui a besoin de Node.js. Le binaire autonome publié sur GitHub ignore donc ces fichiers : pour tout formater, il faut le paquet npm.

Côté style, il règle le problème de l'intro : un seul type de guillemets dans tout le code, sauf pour une chaîne qui contient déjà ce guillemet, où il garde l'autre pour éviter un échappement. Ce qui demandait des plugins chez Prettier est intégré. Le tri des classes Tailwind reprend l'algorithme de prettier-plugin-tailwindcss. Avec Tailwind v4, où la config vit dans le CSS, l'option stylesheet pointe vers ce fichier pour qu'Oxfmt connaisse les utilitaires maison. Le tri des imports reste désactivé par défaut. Le tri du package.json, lui, est actif d'office : ce fichier apparaîtra dans le diff du premier passage.

Mesuré sur les 1 200 fichiers du projet, en mode --check :

PrettierOxfmtÉcart
Sans tri Tailwind18,6 s0,5 senviron 37 fois
Avec tri Tailwind22,1 s (avec prettier-plugin-tailwindcss)2,4 senviron 9 fois

VS Code : l'extension Oxc

Pour avoir les mêmes règles dans l'éditeur que dans le CI, il faut l'extension Oxc (oxc.oxc-vscode) dans le marketplace de VS Code. Elle fonctionne aussi dans Cursor et les autres éditeurs dérivés. Une seule extension pour les deux outils : les diagnostics d'Oxlint dans le code, et le formatage par Oxfmt. Elle utilise l'Oxfmt du projet, il doit donc être installé en dépendance de dev, pas en global.

Pour que toute l'équipe se la voie proposer à l'ouverture du projet :

.vscode/extensions.json
{
  "recommendations": ["oxc.oxc-vscode"]
}

Il faut ensuite la déclarer comme formateur par défaut. La documentation propose ceci :

.vscode/settings.json
{
  "editor.defaultFormatter": "oxc.oxc-vscode",
  "editor.formatOnSave": true
}

Le piège : VS Code fait passer un réglage propre à un langage devant un réglage général, même quand le premier vient de tes réglages utilisateur et le second du workspace. Un "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } défini dans tes réglages utilisateur pour tes autres projets, et Oxc est ignoré sans le moindre message : le fichier est formaté par Prettier à l'enregistrement, et le CI le refuse au push.

La parade : un bloc par langage dans le workspace.

.vscode/settings.json
{
  "[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[css]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[tailwindcss]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[yaml]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[dockercompose]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
  "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true },
}

Les blocs tailwindcss, dockercompose et github-actions-workflow ne sont pas du zèle. Selon les extensions installées, certains fichiers reçoivent un identifiant de langage inattendu : tailwindcss pour un .css, dockercompose pour un fichier Docker Compose, github-actions-workflow pour un workflow GitHub. La commande oxfmt formate tous ces fichiers. Sans le bloc correspondant, l'éditeur ne les formate pas à l'enregistrement, et l'écart ne se voit qu'au CI.


Le bilan

Étape du CIAvantAprès
Lint42 s2 s
Vérification du formatage22 s2 s

En plus du temps : 270 paquets de moins dans le lockfile, un style unique sur tout le code, et les mêmes règles dans l'éditeur et dans le CI. Le lint complet se lance de nouveau en local, sur tout le projet, sans y penser.


Ce qu'il faut garder en tête

Oxfmt est en bêta. Il ne charge pas les plugins Prettier. Les plus répandus ont un équivalent intégré : Tailwind, Svelte, Astro, le tri des imports et celui du package.json. Pour tout autre plugin, vérifie qu'Oxfmt couvre le besoin avant de migrer.

Les plugins JS d'Oxlint sont en alpha. Ils permettent de charger des plugins ESLint qui n'ont pas d'équivalent natif. Si ta config en dépend, teste-les avant de supprimer ESLint.

Le lint typé n'était pas en jeu ici. eslint-config-next n'active aucune règle qui a besoin des types, donc je n'ai rien perdu. Si ta config utilise no-floating-promises et compagnie, Oxlint a un mode type-aware, construit sur le portage natif de TypeScript : regarde où il en est avant de migrer.

Mes sondes couvrent les règles qui comptent pour ce projet, pas les 80 règles une par une. C'est un choix : je voulais vérifier les règles dont l'absence me coûterait quelque chose.


Le prochain goulot : TypeScript

Le typecheck est maintenant l'étape la plus longue du CI, et de loin.

TypeScript 7, le compilateur réécrit en Go, est sorti en juillet 2026, et Next.js 16.3 l'accepte déjà pour vérifier les types. Ce qui me retenait, c'était ESLint. eslint-config-next embarque typescript-eslint, qui s'appuie sur l'API programmatique de TypeScript, absente de la 7.0 et prévue pour la 7.1. En attendant, typescript-eslint 8.71 exige typescript <6.1.0.

Oxlint a son propre parser et ne dépend pas de typescript : le blocage est parti avec ESLint. Prochaine étape, TypeScript 7.


Sources :