Von noImplicitAny bis type-coverage: der praktische Migrationspfad
Wer any unkontrolliert im Code verteilt, hebelt die Typprüfung genau an den Stellen aus, an denen sie am wichtigsten wäre. Dieser Artikel zeigt den praktischen Weg zu einer typsicheren Codebase: noImplicitAny gezielt aktivieren, ESLint-Regeln schrittweise verschärfen, unknown und Generics als Ersatz nutzen und den Fortschritt mit type-coverage messbar machen, ohne die laufende Entwicklung zu blockieren.
Inhaltsverzeichnis
- 1. Warum any den Type-Checker lautlos aushebelt
- 2. noImplicitAny schrittweise aktivieren
- 3. ESLint-Strategie: no-explicit-any von warn zu error
- 4. unknown statt any: Typsicherheit für externe Daten
- 5. Generics statt any[] für wiederverwendbare Funktionen
- 6. Record<string, unknown> statt any für lose Objekte
- 7. Function Overloads statt any-Parameter
- 8. Fortschritt messen mit type-coverage
- 9. any-Code im direkten Vergleich zur typisierten Alternative
- 10. Zusammenfassung
- 11. FAQ
1. Warum any den Type-Checker lautlos aushebelt
any ist in TypeScript kein Typ im eigentlichen Sinn, sondern eine Notausstiegsklappe, die den kompletten Typ-Checker für eine Variable, einen Parameter oder einen Rückgabewert abschaltet. Der Compiler prüft an dieser Stelle nichts mehr: weder Property-Zugriffe, noch Methodenaufrufe, noch die Kompatibilität mit anderen Typen. Das eigentliche Problem zeigt sich aber nicht an der Stelle, an der any hingeschrieben wurde, sondern an jeder Stelle, die diesen Wert später weiterverwendet. Ein einzelnes any in einer tief verschachtelten Utility-Funktion reicht aus, um die Sicherheit eines ganzen Aufrufbaums zu untergraben.
Durch TypeScripts Typinferenz breitet sich any wie ein Fleck aus. Wird der Rückgabewert einer Funktion nicht explizit typisiert und enthält intern any, inferiert der Compiler automatisch any als Rückgabetyp - jeder Aufrufer erbt diese Lücke, ohne eine Warnung zu sehen. Bei generischen Funktionen ist der Effekt noch gravierender: Wird ein generischer Parameter mit any instanziiert, kollabiert die gesamte Typprüfung innerhalb dieser Instanz zu any, selbst wenn die Funktion selbst sauber typisiert ist. In gewachsenen Codebases mit hunderten Modulen genügen wenige solcher Wurzeln, um große Teile der Anwendung effektiv ungetypt zu machen.
2. noImplicitAny schrittweise aktivieren
Das Compiler-Flag noImplicitAny ist die Grundvoraussetzung für eine typsichere Codebase: Es zwingt TypeScript, jeden Parameter, jede Variable und jeden Rückgabewert ohne erkennbaren Typ als Fehler statt als stillschweigendes any zu behandeln. In einer frisch aufgesetzten Codebase aktiviert man das Flag einfach in der tsconfig.json. In einer gewachsenen Legacy-Codebase mit tausenden Dateien führt das sofortige Aktivieren aber zu hunderten Fehlern auf einen Schlag - ein Zustand, den kein Team in einem Rutsch beheben kann, ohne die laufende Feature-Entwicklung zu blockieren.
Der praktikable Weg ist eine schrittweise Migration über eine zweite tsconfig-Datei, die noImplicitAny nur für eine explizite Liste bereits geprüfter Verzeichnisse aktiviert und von der Basis-Konfiguration erbt. Neue Dateien werden dieser Liste hinzugefügt, sobald sie sauber typisiert sind; CI prüft beide Konfigurationen parallel. Die sinnvolle Migrationsreihenfolge beginnt bei Leaf-Modulen und Utility-Funktionen ohne eigene Abhängigkeiten, da sie am schnellsten fehlerfrei werden und als stabile Basis für alle Consumer-Module dienen. Erst wenn diese Fundamentschicht sauber ist, lohnt sich die Migration der aufrufenden Komponenten und Services, die von ihr abhängen.
// tsconfig.json - base config, still permissive for the whole codebase
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": false,
"noImplicitAny": false,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
// tsconfig.strict.json - opt-in noImplicitAny for already migrated files only
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noImplicitAny": true,
"strictNullChecks": true
},
"include": [
"src/utils/**/*.ts",
"src/lib/format-currency.ts",
"src/lib/parse-api-response.ts"
]
}
// package.json - run both checks in CI, strict config only covers migrated files
// "scripts": { "typecheck": "tsc --noEmit", "typecheck:strict": "tsc -p tsconfig.strict.json --noEmit" }
3. ESLint-Strategie: no-explicit-any von warn zu error
Während noImplicitAny nur implizites any erfasst, deckt die ESLint-Regel @typescript-eslint/no-explicit-any zusätzlich jedes bewusst hingeschriebene any: Type-Assertions, explizite Parametertypen und Rückgabetypen. Der erste Schritt in einer Legacy-Codebase ist nicht, die Regel scharf zu schalten, sondern sie auf warn zu setzen und damit eine ehrliche Baseline zu ermitteln, ohne die CI-Pipeline zu blockieren. Diese Zahl, oft mehrere hundert Vorkommen, ist der Ausgangspunkt für jede weitere Planung.
Mit ESLints overrides-Mechanismus lässt sich die Regel pro Verzeichnis unterschiedlich konfigurieren: Verzeichnisse, die vollständig migriert sind, bekommen error, alles andere bleibt vorerst bei warn. Der entscheidende Hebel ist ein Ratchet-Mechanismus über --max-warnings: Das Budget wird beim aktuellen Warnungs-Stand eingefroren und darf mit jedem Merge nur sinken, nie steigen. Jeder Pull Request, der neue any-Vorkommen einführt, lässt die CI-Pipeline fehlschlagen, während bestehende Warnungen schrittweise durch dedizierte Cleanup-Commits abgebaut werden. So verhindert man ein erneutes Anwachsen, ohne sofort alle Bestandswarnungen beheben zu müssen.
// eslint.config.js - flat config with per-directory ratchet for no-explicit-any
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
// Baseline: warn everywhere so CI stays green while we measure the count
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
// Fully migrated directories: any is now a hard error
files: ['src/utils/**/*.ts', 'src/lib/**/*.ts', 'src/api/client.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
},
},
{
// Legacy directory not yet touched: still warn, tracked separately
files: ['src/legacy/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
// CI ratchet: budget only ever goes down, never up
// "lint:any-budget": "eslint . --max-warnings 42"
4. unknown statt any: Typsicherheit für externe Daten
Die häufigste Ausrede für any ist externe, nicht vertrauenswürdige Eingabe: das Ergebnis von JSON.parse, eine API-Antwort oder Nutzereingaben aus einem Formular. In allen diesen Fällen ist unknown der korrekte Typ, nicht any. unknown erlaubt der Variable jeden Wert, verweigert aber jeden Zugriff auf Properties oder Methoden, bis der Typ explizit eingeschränkt wurde. Der Compiler zwingt so zur Auseinandersetzung mit der tatsächlichen Form der Daten, statt sie blind zu vertrauen.
Die Einschränkung erfolgt über eine Type-Guard-Funktion, die mit einem is-Prädikat den Compiler informiert, welcher konkrete Typ nach einer erfolgreichen Prüfung vorliegt. Für API-Antworten prüft die Guard-Funktion typischerweise mit typeof und in, ob die erwarteten Felder vorhanden sind und die richtigen Primitivtypen tragen. Schlägt die Prüfung fehl, wirft die Funktion einen Fehler - der aufrufende Code bleibt danach vollständig typsicher, ohne einen einzigen Cast.
// Type guard narrows unknown to a known shape before use
interface ApiUser {
id: number;
email: string;
isActive: boolean;
}
function isApiUser(value: unknown): value is ApiUser {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as Record<string, unknown>).id === 'number' &&
typeof (value as Record<string, unknown>).email === 'string' &&
typeof (value as Record<string, unknown>).isActive === 'boolean'
);
}
async function fetchUser(id: number): Promise<ApiUser> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isApiUser(data)) {
throw new Error('Unexpected API response shape for user');
}
// data is now narrowed to ApiUser, no cast needed
return data;
}
5. Generics statt any[] für wiederverwendbare Funktionen
Reusable Container- und Utility-Funktionen wie groupBy, chunk oder ein einfacher Cache werden oft mit any[] oder any als Parameter- und Rückgabetyp geschrieben, weil sie mit beliebigen Elementtypen arbeiten sollen. Das Ergebnis ist eine Funktion, die zwar universell einsetzbar ist, aber jede Typinformation an der Aufrufstelle verliert: Der Rückgabewert ist any, egal ob ein Array von Strings oder ein Array von Order-Objekten hineingegeben wurde.
Generics lösen genau dieses Problem, ohne die Wiederverwendbarkeit einzuschränken. Ein generischer Typparameter T bindet den Elementtyp an der Aufrufstelle, sodass der Rückgabetyp automatisch korrekt inferiert wird - eine groupBy-Funktion mit generischem Schlüssel- und Elementtyp liefert exakt das erwartete Record<K, T[]>, ohne dass der Aufrufer irgendetwas casten muss. Der Migrationsaufwand ist überschaubar: Meist genügt es, den any-Parametertyp durch einen generischen Typparameter zu ersetzen und die interne Logik unverändert zu lassen, da Generics zur Laufzeit ohnehin keine Rolle spielen.
// Generic groupBy replaces an any-based version and stays fully typed
function groupBy<T, K extends PropertyKey>(
items: readonly T[],
keyFn: (item: T) => K,
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
interface Order {
id: number;
status: 'pending' | 'shipped' | 'cancelled';
}
const orders: Order[] = [
{ id: 1, status: 'pending' },
{ id: 2, status: 'shipped' },
{ id: 3, status: 'pending' },
];
// ordersByStatus is inferred as Record<'pending' | 'shipped' | 'cancelled', Order[]>
const ordersByStatus = groupBy(orders, (order) => order.status);
6. Record<string, unknown> statt any für lose Objekte
Für lose strukturierte Objekte, etwa Konfigurationsobjekte, Feature-Flags oder dynamisch zusammengesetzte Optionen, greifen viele Entwickler ebenfalls zu any, weil die genaue Form zur Entwicklungszeit unklar erscheint. Record<string, unknown> ist hier fast immer die bessere Wahl: Der Typ drückt exakt aus, was gemeint ist - ein Objekt mit beliebigen String-Keys, dessen Werte im Einzelfall geprüft werden müssen, bevor sie verwendet werden.
Der praktische Unterschied zu any zeigt sich beim Zugriff: config.someKey ist bei any ein beliebiger Wert ohne jede Prüfung, bei Record<string, unknown> zwingt der Compiler dazu, den Wert vor der Verwendung zu typisieren oder mit einem Type Guard einzuschränken. Für Konfigurationsobjekte mit bekannten Pflichtfeldern lohnt sich oft ein hybrider Ansatz: ein Interface mit den bekannten Feldern kombiniert mit einem Index-Signature-Fallback für optionale Erweiterungen, statt das gesamte Objekt auf unknown zu reduzieren.
7. Function Overloads statt any-Parameter
Polymorphe Funktionen, die je nach Eingabetyp unterschiedliches Verhalten zeigen, etwa ein Formatter, der sowohl Zahlen als auch Datums-Objekte annimmt und je nach Typ unterschiedliche Rückgabewerte liefert, werden oft mit any-Parametern geschrieben, weil eine einzelne Signatur die Vielfalt der Eingaben nicht sauber abbilden kann. Das Ergebnis ist eine Funktion, die zwar alles akzeptiert, aber weder dem Aufrufer noch der Implementierung selbst Typsicherheit bietet.
Function Overloads lösen dieses Problem, indem sie mehrere explizite Signaturen vor der eigentlichen Implementierung deklarieren: eine Signatur pro sinnvoller Eingabe-Rückgabe-Kombination. Der Compiler wählt beim Aufruf automatisch die passende Signatur aus und meldet einen Fehler, wenn keine passt. Die Implementierungssignatur selbst darf breiter sein und intern mit einer Union arbeiten, bleibt aber für den Aufrufer unsichtbar. So bekommt jede Kombination aus Eingabe und Rückgabewert exakte Typen, ohne dass irgendwo im Code ein any auftaucht.
8. Fortschritt messen mit type-coverage
Ohne Messung bleibt jede any-Eliminierung ein Gefühl statt ein Fakt. Das npm-Paket type-coverage zählt für jede Datei, wie viele Identifier einen konkreten Typ tragen und wie viele auf any zurückfallen, und aggregiert daraus einen Prozentwert für die gesamte Codebase. Anders als eine reine Fehleranzahl bildet dieser Wert auch implizite any-Stellen ab, die weder ESLint noch der Compiler in der aktuellen Konfiguration melden.
In der CI-Pipeline lässt sich mit type-coverage --at-least 92 ein Mindestwert erzwingen, der den Build fehlschlagen lässt, sobald die Typabdeckung unter die Schwelle fällt. Der entscheidende Vorteil gegenüber einer reinen Momentaufnahme ist der Trend: Wird der Prozentwert bei jedem Merge in ein Dashboard oder Artefakt geschrieben, wird jede schleichende Verschlechterung sofort sichtbar, lange bevor sie zu einem echten Problem wird. Die Schwelle selbst wird wie das ESLint-Budget regelmäßig angehoben, sobald neue Bereiche der Codebase migriert sind.
# package.json: enforce a minimum type-coverage percentage in CI
# "scripts": { "type-coverage": "type-coverage --at-least 92 --detail" }
$ npm run type-coverage
> type-coverage --at-least 92 --detail
src/legacy/order-export.ts:41:12: any
src/legacy/order-export.ts:58:3: any
src/utils/format-price.ts:9:22: any
3 uncovered identifiers found
9532 / 10285 (92.68%)
# CI gate fails the build only when the percentage drops below --at-least
9. any-Code im direkten Vergleich zur typisierten Alternative
Die folgende Übersicht fasst die häufigsten any-Muster aus der Praxis den jeweils empfohlenen typisierten Alternativen gegenüber. Jede Zeile steht für ein wiederkehrendes Szenario aus einer gewachsenen Codebase, von der API-Antwort bis zum catch-Block, und zeigt, welche Änderung ohne großen Aufwand sofort mehr Sicherheit bringt.
| Szenario | any-Variante | Typisierte Alternative | Vorteil |
|---|---|---|---|
| JSON-Antwort parsen | JSON.parse(str) as any |
as unknown + Type Guard |
Fehlerhafte Form fällt beim Parsen auf |
| Wiederverwendbare Utility | function first(arr: any[]): any |
function first<T>(arr: T[]): T | undefined |
Rückgabetyp bleibt an der Aufrufstelle erhalten |
| Lose Konfiguration | config: any |
config: Record<string, unknown> |
Zugriff erzwingt Prüfung vor Verwendung |
| Polymorphe Funktion | function format(value: any): string |
Function Overloads pro Eingabetyp | Compiler wählt passende Signatur automatisch |
| catch-Block | catch (e: any) |
catch (e: unknown) + Narrowing |
Fehlerbehandlung bricht nicht bei falscher Annahme |
Der gemeinsame Nenner aller fünf Zeilen: Keine der typisierten Alternativen verlangt einen radikalen Umbau. unknown statt any bei der Deklaration, ein Type Guard oder Cast an der richtigen Stelle, ein generischer Parameter statt eines konkreten any - jede Änderung ist lokal begrenzt und lässt sich in einem einzelnen Pull Request umsetzen. Genau diese kleinteilige Umsetzbarkeit macht die schrittweise Migration in der Praxis realistisch, während ein kompletter Rewrite in den meisten Teams am fehlenden Zeitbudget scheitert.
Mironsoft
TypeScript-Migration, Type Safety und Tooling für gewachsene Codebases
TypeScript-Codebase professionell entschärfen?
Wir analysieren eure Codebase, ermitteln die tatsächliche any-Quote mit type-coverage und richten eine schrittweise Migration mit ESLint-Ratchet und noImplicitAny-Rollout ein, ohne eure Feature-Entwicklung zu blockieren.
Type-Coverage-Audit
Baseline ermitteln, kritische any-Hotspots priorisieren
ESLint- & tsconfig-Setup
Ratchet-Konfiguration und schrittweiser noImplicitAny-Rollout
Team-Coaching
unknown, Generics und Type Guards im Alltag verankern
10. Zusammenfassung
any schrittweise aus einer bestehenden Codebase eliminieren ist kein einmaliges Projekt, sondern ein kontinuierlicher Prozess mit klaren Etappen: noImplicitAny zuerst für Leaf-Module und Utilities aktivieren, dann Schritt für Schritt auf Consumer-Module ausweiten. ESLint mit @typescript-eslint/no-explicit-any auf warn setzen, eine Baseline ermitteln und die Regel pro Verzeichnis auf error verschärfen, sobald ein Bereich sauber ist. unknown statt any für jede externe Eingabe, Generics statt any[] für wiederverwendbare Funktionen, Record<string, unknown> für lose Objekte und Function Overloads für polymorphe APIs ersetzen die häufigsten any-Fluchtpunkte durch echte Typsicherheit.
Der entscheidende Erfolgsfaktor ist Messbarkeit statt Bauchgefühl. type-coverage liefert eine harte Zahl, die sich in der CI-Pipeline als Mindestschwelle durchsetzen lässt und mit jedem Merge weiter steigen darf, aber nie sinken sollte. In Kombination mit dem ESLint-Ratchet über max-warnings entsteht ein System, das neue any-Vorkommen aktiv verhindert, während die bestehenden schrittweise abgebaut werden, ganz ohne einen riskanten Big-Bang-Rewrite der gesamten Codebase.
any schrittweise eliminieren - Das Wichtigste auf einen Blick
noImplicitAny-Rollout
Über eine zweite tsconfig mit Include-Allowlist schrittweise aktivieren, Leaf-Module zuerst.
ESLint-Ratchet
no-explicit-any zuerst auf warn, dann pro Verzeichnis auf error. max-warnings-Budget sinkt bei jedem Merge.
Typisierte Alternativen
unknown + Type Guards, Generics statt any[], Record<string, unknown>, Function Overloads statt any-Parameter.
Fortschritt messen
type-coverage --at-least als CI-Gate, Trend im Dashboard dokumentieren, Schwelle regelmäßig anheben.