In einem SaaS zur Bildgenerierung habe ich .prettierrc, eslint.config.mjs und sieben Abhängigkeiten aus package.json im selben Commit gelöscht. Das Lockfile verlor 545 Zeilen. Am nächsten Tag habe ich dasselbe in einem anderen Projekt gemacht, und in noch einem. Beim fünften Mal habe ich nichts mehr abgewogen: Repo aufmachen, ESLint und Prettier raus, biome.json schreiben.
Das ist keine Vorliebe mehr. Das ist Urteilskraft, und die sollte man verteidigen können.
Was mich beim ersten Mal dazu gebracht hat
Es war nicht die Geschwindigkeit. Es war der Abhängigkeitsbaum. Die Konfiguration, die ich entfernt habe, sah so aus:
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];
Ein FlatCompat, damit die Flat Config alte Konfigurationen versteht. Ein eslint-config-prettier, dessen einzige Aufgabe es ist, die ESLint-Regeln abzuschalten, die mit Prettier kollidieren. Ein eslint-plugin-tailwindcss und ein prettier-plugin-tailwindcss, die von zwei verschiedenen Seiten aus ähnliche Dinge tun. Sechs Pakete, deren Daseinsgrund darin besteht, dass sich die anderen Werkzeuge nicht gegenseitig in die Quere kommen.
Biome macht Formatierung und Linting im selben Binary. Das Problem verschwindet, weil es keine zwei Werkzeuge mehr gibt, die man koordinieren muss.
Was kaputtgeht, ohne dass dich jemand warnt
Die Migration besteht nicht darin, Dateien zu löschen und weiterzumachen. Sie besteht aus dem Formatierungsdurchlauf direkt danach: 78 angefasste Dateien in einem Commit, in dem du nichts reviewen kannst, weil es nur Anführungszeichen und Einrückungen sind. Das macht man und akzeptiert es.
Das eigentliche Problem kommt, wenn du den Linter richtig einschaltest. In einem Erinnerungs-Bot für Telegram ergab der erste Durchlauf mit den empfohlenen Regeln 60 Fehler in der Web-App. Fast keiner davon war Rauschen:
- Labels ohne Zuordnung. Login-Felder ohne
htmlFor/id. ESLint mit der Next-Config hatte mir das nie gesagt. - Dekorative SVGs. Die Regel erkennt die Kurzform von
aria-hiddennicht; man mussaria-hidden="true"explizit setzen. - Namenskollision. Ein importierter Typ
TaskList, der mit einer Komponente gleichen Namens kollidierte. Kompilierte einwandfrei und war falsch. - Keys über den Index. In einer Komponente mit rotierendem Text war der Key jedes Zeichens der Index aus dem
map. Die Lösung war nicht, die Regel stummzuschalten: Ich habe den absoluten Offset im Memo vorberechnet, um einen stabilen Key zu bekommen.
Das ist der interessante Punkt. Von den 60 waren die meisten echte Fehler, die seit Monaten dort lagen. Der Rest wurde mit dokumentierten Unterdrückungen gelöst: ein Effekt mit nur einem Durchlauf, Schleifen über Skeletons fester Größe, ein !important, das react-remove-scroll-bar überschreibt, <img> für OAuth-Avatare.
Ein biome-ignore ohne erklärenden Kommentar ist Schulden. Mit Kommentar ist es eine Entscheidung.
Die Geschwindigkeit zählt wegen des Ortes, an dem du das Linting unterbringen kannst
Das ist das, was in Vergleichen am häufigsten auftaucht und am schlechtesten erklärt wird. Die Zahl ist egal. Wichtig ist, wo du den Befehl einhängen kannst, wenn er wenig Zeit braucht.
Mit ESLint lebte das Linting in der CI, denn es in den lokalen Build zu packen hieß, zum Warten verdammt zu sein. Mit Biome sah das Skript dann so aus:
"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."
Das Linting vor dem Build. Wenn es rot ist, kompilierst du nicht. Diese Zeile macht den ganzen Unterschied: Der Fehler wird nicht mehr fünfzehn Minuten später in einem Runner entdeckt, sondern bevor der Build überhaupt startet. Mit einem langsamen Werkzeug ist diese Zeile unerträglich und du nimmst sie irgendwann wieder raus.
Was du verlierst
Zwei Dinge, und sie sollten benannt werden.
Das erste ist das Plugin-Ökosystem. Wenn du von einem sehr speziellen ESLint-Plugin abhängst, gibt es das nicht und wird es wahrscheinlich auch nicht geben. Ich habe das beim Entfernen von eslint-plugin-tailwindcss geprüft: Die Klassenreihenfolge übernimmt der Formatter von Tailwind, nicht der Linter, also habe ich es nicht vermisst. Aber das ist ein Glücksfall, keine allgemeine Regel.
Das zweite ist dümmer und macht mehr Ärger: Die Standardkonfiguration ist nicht überall dieselbe. Ein Projekt kam mit "indentStyle": "tab" heraus und ein anderes mit "indentStyle": "space" und "indentWidth": 2. Wochen später musste ich einen Commit machen, dessen einziger Inhalt war, in biome.json Tabs durch Leerzeichen zu ersetzen, damit alles gleich ist. Pinne das $schema auf die exakte Version und kopiere dieselbe biome.json zwischen den Projekten. Der Komfort dieses Werkzeugs liegt darin, dass es fast keine Konfiguration hat; nutze das aus und halte dir nur eine.
Mein Qualitäts-Stack heute
Biome, TypeScript im Strict Mode, Tests und knip für toten Code. Viel mehr nicht.
Knip ist das Teil, das am wenigsten Leute benutzen und das am meisten überrascht. Ein Durchlauf im Telegram-Bot löschte 19 tote Dateien und 21 ungenutzte Abhängigkeiten: @radix-ui/*-Unterpakete, die mit dem Meta-Paket redundant waren, @react-three/fiber und three komplett, date-fns, und eslint, das Monate nach dem Rauswurf noch installiert war. Es kommt auch ein ganzer Cluster aus Actions und Use Cases zum Vorschein, den niemand mehr aufrief.
Aber knip bleibt bewusst außerhalb der CI. In einem großen Repo ergab der erste Durchlauf 107 bereits vorhandene Funde: Hängst du es ans Gate, steht es ab Tag eins auf Rot und du ignorierst das Rot irgendwann. Es läuft als manuelles Werkzeug, alle paar Monate, mit dem Ausmisten in einem eigenen Commit und vor dem Merge mit tsc und den Tests verifiziert.
Die Konfiguration muss bei den Entrypoints explizit sein, sonst löscht sie dir die halbe Anwendung. In einem Monorepo mit Next muss man ihr von Hand sagen, welche Dateien Einstiegspunkte sind:
"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]
Die von shadcn generierten Komponenten kommen in ignore, weil es immer mehr davon gibt, als du benutzt, und das normal ist. sharp und tailwindcss kommen in ignoreDependencies, weil sie niemand importiert, sondern sie aus dem Build heraus verwendet werden.
Ein langsamer Linter ist ein Linter, der in einem separaten Job endet, den niemand anschaut. Ein schneller Linter kommt in den Build und wird zum Boden des Projekts.
