Wenn moderne Technologien nerven: Eine DX-Odyssee mit tRPC, Drizzle und TypeScript
Die Optimierung einer umfangreichen TypeScript-Monorepo-Entwicklungsumgebung mittels Arktype statt Zod, strukturierter Project References und angepasster VSCode-Einstellungen reduziert Kompilier- und Autocomplete-Dauern auf ein akzeptables Maß.
Endlich konnte ich tRPC ausprobieren. Die Library ermöglicht vollständig typesichere APIs zwischen Client und Server. Außerdem konnte ich Drizzle mal richtig auf Herz und Nieren testen. Für das Projekt mussten wir eine ziemlich umfangreiche Datenbank mit über 100 Tabellen aufsetzen. Anfangs lief alles gut. Die Codebase wuchs, und mit ihr die Kompilierzeiten. Der TypeScript-Compiler wurde mit jeder neuen Tabelle und jedem neuen Endpoint spürbar langsamer.
Und ja, das GIF läuft in Echtzeit.
Mit modernem Stack zu arbeiten ist schön und gut. Aber die Developer Experience war eine Katastrophe. Selbst kleine Features wurden zur Geduldsprobe, weil niemand Lust hatte, ewig auf eine mehrere Minuten blockierte IDE zu warten. Entweder man merkte sich das gesamte Projekt (was bei knapp zwei Millionen Zeilen keine Option ist), oder suchte sich mühsam durch die Files. Beides war nicht praktikabel, beides war frustrierend. Klar war: So konnte es nicht weitergehen.
Und damit begann an einem Wochenende meine verzweifelte Suche nach einer Lösung. Ziel war es, das Monorepo mit seinen 5+ Apps und 15+ Subpaketen so anzupassen, dass man nicht direkt die Lust verlor, überhaupt noch an diesem Projekt zu arbeiten.
Bottleneck-Suche
Als erfahrener Entwickler weiß man: Das Problem sitzt oft vor dem Bildschirm. Also fing ich an, unseren Code zu durchforsten. In der Firma bin ich nicht ganz zu Unrecht dafür bekannt, dass ich beim TypeSystem gerne mal übertreibe. Ein Beitrag aus dem TypeScript-Wiki gab mir erste Hinweise.
Der Code war unauffällig. Die einzige Ausnahme war zod
.
zod
ist eine beliebte Library für Laufzeit-Validierung in TypeScript. In der Regel hat man damit keine Probleme, aber bei einem so großen Projekt macht Kleinvieh eben auch Mist.
Ich warf fast alle zod
-Typen raus und ersetzte sie durch arktype
, eine alternative Library, die deutlich schneller vom TypeScript-Compiler verarbeitet werden kann.
Ein kleiner Vergleich:
// vorher mit zod
const User = z.object({
name: z.string(),
age: z.number(),
});
// nachher mit arktype
const user = a.type({ name: "string", age: "number" });
Und tatsächlich: Die kritischen Stellen waren verschwunden. Laut TypeScript-Compiler Performance Profile gab es jetzt keine eindeutigen Bottlenecks mehr. Nur leider merkte man davon im Alltag so gut wie nichts. Die Kompilierzeit war zwar messbar kürzer, aber die IDE blieb träge.
Da das Ganze nichts brachte, suchte ich weiter nach der Ursache. Schließlich stieß ich auf dieses Issue im tRPC-Repo. Dort wurde erklärt, dass tRPC einen erheblichen Einfluss auf die TypeScript-Performance haben. Immerhin gab es in der Diskussion auch gleich einen Lösungsansatz. Das Problem verstärkt sich in Kombination mit Drizzle und Zod, da Drizzle durch seine generischen Typen für komplexe Datenbank-Queries und Zod durch die umfassenden Schema-Validierungen viele tief verschachtelte Typkonstruktionen erzeugt, die der TypeScript-Compiler bei jeder Code-Änderung erneut voll auflösen muss.
Vorbauen statt Neubauen
Die Idee war einfach: Libraries oder Module, die lange zum Bauen brauchen, kann man vorab kompilieren. Dann muss TypeScript beim Entwickeln nicht mehr alles neu analysieren, sondern nur noch prüfen, ob die Typen korrekt verwendet werden.
Wir haben das Projekt also manuell in Teilbereiche aufgeteilt – zum Beispiel in API, Datenbankzugriff und gemeinsame Typen. Für jedes dieser Subprojekte starteten wir einen eigenen tsc --watch
-Prozess:
cd packages/api && tsc --watch
cd packages/shared && tsc --watch
# und so weiter
Die generierten .d.ts
-Dateien wurden dann im Hauptprojekt verwendet. Die Hoffnung war, dass TypeScript nun nur noch gegen die vorgebauten Typen prüft, anstatt das komplette Projekt ständig neu zu analysieren.
Und tatsächlich: Die Autocompletion wurde wieder nutzbar. Die IDE fühlte sich deutlich schneller an. Für einen kurzen Moment dachte ich, das Problem sei gelöst.
Doch kaum hatte man etwas in einem Subprojekt geändert, dauerte es plötzlich wieder ein bis zwei Minuten, bis alles durchgebaut war. Manche Prozesse liefen mehrfach, andere blockierten sich gegenseitig. Hin und wieder hängte sich der Build komplett auf. Das eigentliche Problem war klar: Die einzelnen tsc --watch
-Prozesse wussten nichts voneinander.
Project References und VSCode verstehen
Unsere tsc --watch
-Prozesse dachten sie seien komplett unabhängig voneinander. Sie kannten weder ihre Abhängigkeiten noch die richtige Reihenfolge. Genau dafür gibt es ein wenig genutztes Feature von TypeScript: sogenannte Project References. Damit lässt sich in der tsconfig.json
definieren, wie Projekte miteinander verknüpft sind.
Wir richteten das Root-Projekt so ein, dass es direkt auf die Subprojekte verweist:
Im packages/api
-Modul legten wir fest, dass es von packages/shared
abhängt:
// tsconfig.json in packages/api
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist"
},
"references": [
{ "path": "../shared" }
],
"include": ["src"]
}
Auch packages/shared
musste natürlich als Composite-Projekt eingerichtet sein:
// tsconfig.json in packages/shared
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist"
},
"include": ["src"]
}
Das Ergebnis war ein einziger tsc --watch
-Prozess, der alle Projekte koordinieren konnte. Eine Codeänderung löste jetzt einen gezielten Rebuild aus – und das Ganze dauerte nur noch etwa drei Sekunden.
Man kann sich das Setup jetzt wie ein Netzwerk vorstellen: Die einzelnen Pakete sind Knoten, die alle klar definierte Wege (References) zueinander haben. Und VSCode nimmt jetzt nicht mehr den Umweg über den ganzen Quellcode, sondern greift direkt auf die fertig gebauten
.d.ts
-Dateien zu.
Doch mit der Einführung von Project References kam ein altes Problem wieder zum Vorschein: Die Autocompletion war erneut unglaublich langsam.
Der Grund: VSCode verwendet seinen eigenen TypeScript-Server, unabhängig vom laufenden Build-Prozess. VSCode analysiert das Projekt bei jeder Autovervollständigng komplett neu, erkennt die References – und baut dann das gesamte Projekt immer wieder vollständig durch. Ohne Cache. Ohne Rücksicht auf bereits gebaute Dateien.
Die Lösung war ein einzelner Konfigurationsschalter:
{
"compilerOptions": {
"disableSourceOfProjectReferenceRedirect": true
}
}
Mit dieser Einstellung zwingt man VSCode dazu, direkt in den generierten .d.ts
-Dateien nach Typinformationen zu suchen, statt den kompletten Source-Code neu zu analysieren.
Und was soll ich sagen? Es funktionierte.
Fazit
Auch wenn der Watch-Prozess und die Autocompletion jetzt endlich wieder performant und stabil laufen, bringt dieses Setup einen gewissen Wartungsaufwand mit sich. Kommt ein neues Subprojekt hinzu, muss es nicht nur in der package.json
berücksichtigt werden, sondern auch korrekt in die entsprechenden tsconfig.json
-Dateien aufgenommen werden. Nur dann kann der TypeScript-Compiler zuverlässig arbeiten – und nur dann bleibt die Developer Experience angenehm.
Trotzdem: Lieber etwas mehr initiale Pflege, als jeden Tag gegen eine IDE anzukämpfen.
)
)
)
)
)
)
)
)
)
)
)
)