TypeScript Strict Mode für Legacy-Codebases: Die inkrementelle Einführungsstrategie, die nicht alles kaputt macht
Jemand im Team öffnet die tsconfig.json, ändert "strict": false zu "strict": true und startet den Build. Dreitausendvierhundert Fehler. Der Branch wird noch am selben Nachmittag aufgegeben, und das Ticket wandert mit dem Kommentar "braucht einen eigenen Sprint" ins Backlog. Achtzehn Monate später liegt es immer noch dort, und die Codebase ist um weitere zwanzigtausend Zeilen lose typisierten Code gewachsen.
Das ist der normale Ausgang einer TypeScript-Strict-Mode-Migration in einer Legacy-Codebase, wenn sie als eine einzige Änderung versucht wird, und das Scheitern ist strukturell, keine Frage der Disziplin. Ein repositoryweiter Flag-Flip verwandelt ein graduelles Problem in ein atomares. Niemand kann einen Diff über viertausend Dateien reviewen, niemand kann Features ausliefern, solange er offen ist, und in dem Moment, in dem er mit main kollidiert, ist die Arbeit verloren. Die Alternative ist nicht, härter am selben Ansatz zu arbeiten. Sie ist, die Migrationseinheit vom Repository auf die Datei zu ändern.
Warum eine TypeScript-Strict-Mode-Migration in der Legacy-Codebase als eine Änderung scheitert
Drei Dinge gehen gleichzeitig schief, wenn strict global aktiviert wird.
Die Fehlerzahl ist nicht gleichmäßig verteilt. In einer typischen Applikation mit 50.000 Zeilen erzeugt eine Handvoll Dateien einen überproportionalen Anteil der Fehler, meist die, die API-Response-Formen, Formular-State oder ursprünglich als JavaScript geschriebenen Code berühren. Diese Dateien sauber zu fixen bedeutet, echte Entscheidungen über Nullability im Domainmodell zu treffen, und genau diese Arbeit lässt sich am Sprint-Ende nicht durchpeitschen.
Der Migrations-Branch überlebt den Kontakt mit dem Team nicht. Jeder Fix berührt eine Typdefinition, die andere Dateien importieren, also kollidiert der Branch ständig. Langlebige Refactoring-Branches in einem aktiv entwickelten Repository haben eine kurze Halbwertszeit, und ein Strict-Mode-Branch ist der Extremfall.
Und die Änderung ist reine Kosten ohne Ratsche. Stirbt der Branch, ist nichts geschützt. Neuer Code von nächster Woche ist genauso lose wie der Code vom letzten Jahr. Der Wert von Strict Mode verzinst sich nur, wenn er durchgesetzt wird, und ein aufgegebener Branch setzt nichts durch.
Der Fix für alle drei Probleme ist derselbe. Lass Strict Mode für eine wachsende Liste von Dateien gelten statt für das Repository, setze diese Liste vom ersten Tag an in CI durch, und lass die Liste wachsen, während Leute Code anfassen, den sie ohnehin anfassen wollten.
Das Allowlist-Modell in der Praxis
Der Mechanismus ist eine zweite tsconfig, die strenger ist als die Haupt-Config und für eine schmale Menge von Dateien gilt. Deine bestehende tsconfig.json bleibt exakt wie sie ist, also sind Editor, Dev-Server und Produktions-Build unberührt. Daneben:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": true,
"noEmit": true
},
"include": [],
"files": [
"src/lib/money.ts",
"src/lib/date-range.ts",
"src/domain/invoice.ts"
]
}
Speichere sie als tsconfig.strict.json und ergänze ein Script:
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:strict": "tsc -p tsconfig.strict.json"
}
CI führt beide aus. Das erste hält die bestehende Messlatte. Das zweite schlägt nur fehl, wenn eine Datei auf der Liste zurückfällt. Wer eine Datei migriert, fügt im selben Pull Request wie die Fixes eine Zeile zum files-Array hinzu. Das macht die Migration in normal großen Häppchen reviewbar und gibt ihr ein natürliches Zuhause in gewöhnlicher Feature-Arbeit.
Ein Vorbehalt, den du kennen solltest, bevor du dich festlegst: tsc folgt den Imports jeder gelisteten Datei, also kann ein schlecht typisiertes Modul tief im Graphen trotzdem Diagnosen hochspülen. Starte die Liste mit Leaf-Modulen, die wenig importieren. Utility- und Domain-Module sind die natürlichen ersten Kandidaten, und sie sind auch dort, wo Nullability-Bugs den größten Schaden anrichten.
Es gibt eine invertierte Variante derselben Idee, bei der strict global aktiviert ist und unmigrierte Dateien einen Opt-out-Kommentar tragen. Das Paket typescript-strict-plugin implementiert das mit einem // @ts-strict-ignore-Marker. Es hat eine attraktive Eigenschaft: Neue Dateien sind per Default strict, ohne dass jemand daran denken muss, sie einer Liste hinzuzufügen. Es hat entsprechende Kosten: Du beginnst damit, jeder Datei im Repository einen Kommentar hinzuzufügen, und das Plugin sitzt zwischen deinem Editor und tsc. Beide Modelle funktionieren. Wähle die Allowlist, wenn du kein neues Tooling im Dependency-Tree willst, und das invertierte Modell, wenn du viele neue Dateien erwartest und der Default zugunsten von Strictness ausfallen soll.
Eine Anmerkung zu @ts-nocheck, das in dieser Diskussion oft auftaucht: Es deaktiviert die gesamte Typprüfung in einer Datei, nicht nur die strikten Checks. Für diesen Job ist es ein stumpfes Werkzeug, und in großem Stil eingesetzt entfernt es die Prüfung, die du bereits hattest. Greif nur für generierte Dateien danach, die du nicht kontrollierst.
Project References sind eine dritte Option, und sie sind nur dann die richtige, wenn die Codebase bereits in wirklich separate Pakete mit eigenen Build-Grenzen aufgeteilt ist. References allein zum Gaten von Strictness einzuführen heißt, deinen Build für ein Linting-Anliegen umzubauen. Das ist sehr viel Arbeit für ein Ergebnis, das der Zwei-Dateien-tsconfig-Ansatz an einem Nachmittag liefert.
Welche Flags zuerst, und warum sich der übliche Rat hier umkehrt
Die gängige Empfehlung lautet, zuerst die billigen Flags zu aktivieren: alwaysStrict, noImplicitThis, strictBindCallApply, dann noImplicitAny, und strictNullChecks sowie strictPropertyInitialization bis zum Schluss aufzuheben, weil sie die meisten Fehler produzieren. Diese Reihenfolge ist korrekt, wenn du das ganze Repository auf einmal migrierst, weil sie die Höhe des ersten Hügels minimiert.
Im Allowlist-Modell ändert sich die Rechnung. Der Explosionsradius ist bereits durch die Dateiliste begrenzt, also ist die Fehlerzahl eines Flags nicht mehr die Beschränkung. Wert pro Datei ist es. In strictNullChecks steckt fast die gesamte Verhinderung von Laufzeit-Bugs, denn es ist das Flag, das undefined davon abhält, stillschweigend einen nicht-optionalen Typ zu erfüllen. Eine Datei unter allem außer strictNullChecks zu migrieren heißt, diese Datei zweimal zu besuchen und den Großteil des Nutzens erst beim zweiten Besuch zu bekommen. Aktiviere das volle strict-Meta-Flag für Allowlist-Dateien von Anfang an und akzeptiere, dass jede Datei länger dauert.
Zwei Flags verdienen eine getrennte Behandlung. strictPropertyInitialization verträgt sich schlecht mit Dependency Injection und mit ORM-Entity-Klassen, in denen Properties legitimerweise außerhalb des Konstruktors zugewiesen werden. Definite Assignment Assertions sind dort die Standard-Fluchttür, und ihr Einsatz in Entity-Klassen ist eine vernünftige Engineering-Entscheidung, kein Kompromiss. exactOptionalPropertyTypes ist überhaupt nicht Teil von strict und sollte ausgeschaltet bleiben, bis der Rest stabil ist, denn es ändert die Bedeutung jeder optionalen Property in deinen API-Typen auf einmal.
Falls dein Team intern noch nicht geklärt hat, warum das alles die Mühe wert ist: Das Argument für die Flags selbst behandeln wir in TypeScript Strict Mode: Warum dein Team ihn aktivieren sollte. Dieser Beitrag setzt voraus, dass die Entscheidung gefallen ist und das Problem die Umsetzung im großen Maßstab ist.
Wo automatisiertes Tooling hilft und wo nicht
Airbnbs ts-migrate ist das in diesem Kontext am häufigsten genannte Tool. Es ist für einen bestimmten Job wirklich nützlich, nämlich den mechanischen Durchlauf, der explizite Annotationen einfügt, wo der Compiler sonst any inferieren würde, und für die Konvertierung von JavaScript zu TypeScript. Es lohnt sich, wenn noImplicitAny hunderte trivial annotierbare Parameter produziert. Prüfe seinen Wartungsstatus gegen deine TypeScript-Version, bevor du einen Plan darauf aufbaust, denn es hat sich in den letzten Jahren langsam bewegt.
Genauso klar sollte sein, was es nicht kann. ts-migrate löst noImplicitAny-Verstöße, indem es any in den Quellcode schreibt, was den Compiler zum Schweigen bringt, ohne Typsicherheit hinzuzufügen. Das ist ein legitimer Zwischenzustand, wenn die Annotationen als To-do-Liste behandelt werden, und eine dauerhafte Regression, wenn nicht. Wenn du es einsetzt, konfiguriere es so, dass es einen markanten Alias wie type TODO = any ausgibt, damit die Restarbeit greppbar und zählbar ist, und stelle diese Zahl irgendwo hin, wo das Team sie sieht.
Für strictNullChecks ist Automatisierung deutlich schwächer, denn der korrekte Fix ist eine Domain-Entscheidung. Wenn der Compiler user.company.name anmeckert, kann die richtige Antwort ein Null-Check sein, ein Early Return, eine Non-Null-Assertion mit einer Invariante dahinter, oder eine Änderung des Typs, weil der API-Vertrag von Anfang an falsch war. Nur die letzte davon entfernt tatsächlich eine Klasse von Bugs, und kein Codemod kann erkennen, welche zutrifft. Plane das als menschliche Arbeit ein.
Das CI-Gate, das es dauerhaft macht
Alles oben ist verschwendet, wenn die Liste stillschweigend schrumpfen kann. Zwei Checks reichen.
Der erste ist npm run typecheck:strict, das den Build bei jedem Fehler in einer Allowlist-Datei fehlschlagen lässt. Der zweite ist ein Schutz gegen Entfernung: ein kleines Script, das das files-Array in tsconfig.strict.json mit der Version auf dem Main-Branch vergleicht und fehlschlägt, wenn ein Pfad verschwunden ist. Ohne ihn ist der Weg des geringsten Widerstands unter Deadline-Druck, eine Zeile aus der Config zu löschen, und das wird passieren.
Ergänze einen dritten Check, wenn du dir die Diskussion leisten kannst: Verlange, dass jede in einem Pull Request geänderte Datei, die noch nicht auf der Liste steht, hinzugefügt wird. Das ist die Ratsche, die die Migration zu Ende bringt, denn sie koppelt Strictness an den Code, den das Team ohnehin anfasst, und das ist per Definition der Code, in dem sich Typsicherheit am meisten auszahlt. Teams, die diesen Schritt überspringen, migrieren typischerweise die einfachen zwanzig Prozent und bleiben dann stecken.
Was es wirklich kostet
Für eine Applikation mit 50.000 Zeilen und einem typischen Mix aus Domainlogik, React-Komponenten und API-Clients sieht eine realistische Form so aus:
Das Aufsetzen der beiden tsconfig-Dateien, der Scripts und der CI-Checks dauert unter einem Tag. Die Migration der ersten Tranche von Leaf-Utility- und Domain-Modulen, vielleicht fünfzehn bis dreißig Dateien, dauert zwei bis vier Tage und ist die Phase, in der das Team die Muster lernt. Von da an liegen die Grenzkosten bei grob zehn bis dreißig Minuten pro Datei für gewöhnliche Module, und erheblich darüber für die kleine Zahl von Dateien, die externe API-Responses modellieren, denn diese erzwingen das Nullability-Gespräch, dem die Codebase bisher ausgewichen ist.
Die Summe landet meist irgendwo zwischen vier und acht Entwicklerwochen, verteilt über zwei bis drei Quartale, nicht am Stück ausgegeben. Wichtiger als die Summe ist, dass der Wert ab Woche eins ankommt, denn die Ratsche stoppt das Wachstum der untypisierten Fläche, während die Migration noch läuft.
Das Fehlermuster, auf das du achten solltest, ist eine Migration, die bei vierzig Prozent stehen bleibt, weil die verbleibenden Dateien die wirklich harten sind und niemand die Domain-Entscheidungen darin verantworten will. Das ist kein TypeScript-Problem. Es ist meist ein Zeichen, dass das Datenmodell Mehrdeutigkeiten hat, die älter sind als die Migration, und der Compiler hat sie lediglich sichtbar gemacht. Diese Dateien als eigene kleine Legacy-Code-Optimierung zu behandeln, mit Zeit dafür, das Modell zu fixen statt nur die Typen, bringt eine steckengebliebene Migration wieder in Bewegung.
Eine zweite Meinung zum Plan einholen
Der Großteil der Schwierigkeit in dieser Arbeit ist Urteilsvermögen, nicht Syntax: welche Dateien zuerst migriert werden, wo eine Non-Null-Assertion ehrlich ist und wo sie eine Lüge ist, und wie die Flags gegen die bereits zugesagte Roadmap sequenziert werden. Wenn du auf eine große TypeScript-Codebase schaust und vor der Zusage eines Quartals eine Außensicht auf die Reihenfolge willst, ist genau dieses Gespräch das, wofür Code-Quality-Beratung da ist.
Wir arbeiten an genau dieser Art inkrementeller Härtung in PHP-, Symfony-, React- und Next.js-Codebases in Europa. Schreib an hello@wolf-tech.io oder wirf einen Blick auf wolf-tech.io, und wir sagen dir offen, ob sich die Migration jetzt lohnt oder ob etwas anderes in der Codebase das Quartal eher verdient.

