Dans un SaaS de génération d'images, j'ai supprimé .prettierrc, eslint.config.mjs et sept dépendances de package.json dans le même commit. Le lockfile a perdu 545 lignes. Le lendemain j'ai fait la même chose sur un autre projet, puis sur un autre. À la cinquième fois je n'évaluais plus rien : j'ouvrais le dépôt, je retirais ESLint et Prettier, et j'écrivais biome.json.
Ce n'est plus une préférence. C'est un jugement, et il vaut mieux savoir le défendre.
Ce qui m'a poussé la première fois
Ce n'est pas la vitesse. C'est l'arbre de dépendances. La configuration que j'ai retirée était celle-ci :
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];
Un FlatCompat pour que la flat config comprenne les anciennes configurations. Un eslint-config-prettier dont l'unique travail est d'éteindre les règles d'ESLint qui entrent en conflit avec Prettier. Un eslint-plugin-tailwindcss et un prettier-plugin-tailwindcss qui font des choses semblables depuis deux côtés différents. Six paquets dont la raison d'être est que les autres outils ne se marchent pas dessus.
Biome fait le formatage et le lint dans le même binaire. Ce problème disparaît parce qu'il n'y a plus deux outils à coordonner.
Ce qui casse sans que personne ne prévienne
La migration, ce n'est pas supprimer des fichiers et continuer. C'est le passage de formatage juste après : 78 fichiers touchés dans un commit où vous ne pouvez rien relire parce que ce ne sont que des guillemets et des indentations. On le fait et on l'accepte.
Le vrai problème arrive quand vous allumez le linter pour de bon. Dans un bot de rappels sur Telegram, la première passe avec les règles recommandées a donné 60 erreurs sur le site. Presque aucune n'était du bruit :
- Labels non associés. Des champs du login sans
htmlFor/id. ESLint avec la config de Next ne me l'avait jamais signalé. - SVG décoratifs. La règle ne reconnaît pas la forme abrégée d'
aria-hidden; il faut mettre unaria-hidden="true"explicite. - Collision de noms. Un type
TaskListimporté qui entrait en conflit avec un composant du même nom. Ça compilait parfaitement et c'était faux. - Clés par index. Dans un composant de texte rotatif, la clé de chaque caractère était l'index du
map. La solution n'a pas été de faire taire la règle : c'était de précalculer le décalage absolu dans le memo pour avoir une clé stable.
C'est là le point intéressant. Sur les 60, la majorité étaient de vraies erreurs qui traînaient depuis des mois. Le reste s'est réglé avec des suppressions documentées : un effet à exécution unique, des boucles de squelettes de taille fixe, un !important qui écrase react-remove-scroll-bar, des <img> d'avatars OAuth.
Un biome-ignore sans commentaire expliquant pourquoi, c'est de la dette. Avec commentaire, c'est une décision.
La vitesse compte par l'endroit où elle vous laisse mettre le lint
C'est ce qui revient le plus dans les comparatifs et ce qui s'explique le plus mal. Le chiffre n'a pas d'importance. Ce qui compte, c'est où vous pouvez brancher la commande quand elle est rapide.
Avec ESLint, le lint vivait dans la CI, parce que le mettre dans le build local revenait à se condamner à attendre. Avec Biome, le script est devenu ceci :
"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."
Le lint devant le build. Si c'est rouge, vous ne compilez pas. Cette ligne fait toute la différence : l'erreur n'est plus découverte quinze minutes plus tard sur un runner, elle l'est avant que le build ne démarre. Avec un outil lent, cette ligne est insupportable et vous finissez par la retirer.
Ce que vous perdez
Deux choses, et elles méritent d'être nommées.
La première, c'est l'écosystème de plugins. Si vous dépendez d'un plugin ESLint très précis, il n'existe pas et n'existera probablement jamais. Je l'ai vérifié en retirant eslint-plugin-tailwindcss : l'ordre des classes est réglé par le formateur de Tailwind, pas par le linter, donc il ne m'a pas manqué. Mais c'est un cas chanceux, pas une règle générale.
La seconde est plus bête et plus pénible : la configuration par défaut n'est pas la même partout. Un projet est sorti avec "indentStyle": "tab" et un autre avec "indentStyle": "space" et "indentWidth": 2. Des semaines plus tard, j'ai dû faire un commit dont le seul contenu était de remplacer les tabulations par des espaces dans biome.json pour que tout soit identique. Fixez le $schema avec la version exacte et copiez le même biome.json d'un projet à l'autre. Le confort de cet outil, c'est qu'il n'a presque pas de configuration ; profitez-en et n'en ayez qu'une seule.
Ma stack qualité aujourd'hui
Biome, TypeScript en mode strict, des tests, et knip pour le code mort. Rien de plus.
Knip est la pièce que le moins de gens utilisent et celle qui surprend le plus. Une passe sur le bot Telegram a supprimé 19 fichiers morts et 21 dépendances inutilisées : des sous-paquets @radix-ui/* redondants avec le méta-paquet, @react-three/fiber et three en entier, date-fns, et eslint, toujours installé des mois après l'avoir retiré. Il fait aussi ressortir tout un groupe d'actions et de cas d'usage que plus personne n'appelait.
Mais knip reste hors de la CI, volontairement. Dans un gros dépôt, la première passe a donné 107 résultats préexistants : l'accrocher au gate le met au rouge dès le premier jour et vous finissez par ignorer le rouge. Il fonctionne comme un outil manuel, tous les quelques mois, avec l'élagage dans son propre commit et vérifié avec tsc et les tests avant de merger.
La configuration doit être explicite sur les points d'entrée, sinon il vous supprime la moitié de l'application. Dans un monorepo avec Next, il faut lui déclarer à la main quels fichiers sont des points d'entrée :
"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]
Les composants générés par shadcn vont dans ignore parce qu'il y en a toujours plus que ce que vous utilisez et que c'est normal. sharp et tailwindcss vont dans ignoreDependencies parce que personne ne les importe : ils sont utilisés depuis le build.
Un linter lent est un linter qui finit dans un job à part que personne ne regarde. Un linter rapide entre dans le build et devient le socle du projet.
