wenn ein Compile-Fehler genauso zählt wie ein Laufzeit-Bruch
Eine Typänderung, die Konsumenten-Code zum Compile-Fehler macht, ist ein Breaking Change, auch ohne jede Änderung am Laufzeitverhalten. Backwards Compatibility bei Typ-Änderungen bedeutet, Overloads, Deprecation-Kommentare, API-Reports und Semantic Versioning gezielt einzusetzen, damit eine TypeScript-Bibliothek wachsen kann, ohne bestehende Konsumenten bei jedem Release zu überraschen.
Inhaltsverzeichnis
- 1. Warum Typänderungen Breaking Changes sind, auch ohne Laufzeit-Änderung
- 2. Kategorien von Typänderungen: erweiternd vs. einschränkend
- 3. Deprecation-Strategie mit @deprecated statt sofortigem Entfernen
- 4. Overloads nutzen um alte und neue Signaturen zu unterstützen
- 5. API-Reports zur automatisierten Erkennung von Breaking Changes
- 6. SemVer für Typen: wann Major, Minor, Patch
- 7. Migration Guides und Codemods für Konsumenten
- 8. Testing: Type-Level-Tests gegen Konsumenten-Codebasen
- 9. Strategien für Typänderungen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Typänderungen Breaking Changes sind, auch ohne Laufzeit-Änderung
Bei klassischer Softwareentwicklung ohne statische Typen gilt ein Breaking Change als eine Änderung, die zur Laufzeit ein anderes Ergebnis liefert oder einen Fehler wirft. In einer TypeScript-Bibliothek reicht das nicht als Definition, denn eine Typänderung kann bestehenden, funktionierenden Konsumenten-Code zum Compile-Fehler machen, obwohl sich am tatsächlichen Programmverhalten überhaupt nichts ändert. Wird beispielsweise ein Parameter von string auf string | number erweitert, kompiliert bestehender Code weiter, wird der Parameter dagegen von string | number auf nur string eingeschränkt, bricht jeder Aufruf, der bisher eine Zahl übergeben hat.
Backwards Compatibility muss deshalb für eine TypeScript-Bibliothek zwei getrennte Ebenen berücksichtigen: die Laufzeitebene, auf der klassische Tests greifen, und die Typebene, auf der nur der Compiler selbst prüfen kann, ob bestehender Code weiterhin durchläuft. Ein Team, das ausschließlich Laufzeittests als Sicherheitsnetz nutzt, übersieht regelmäßig Typänderungen, die für Konsumenten trotzdem einen Major-Release-würdigen Bruch darstellen.
2. Kategorien von Typänderungen: erweiternd vs. einschränkend
Für die Einordnung einer Typänderung hilft die Unterscheidung zwischen erweiternden und einschränkenden Änderungen. Eine erweiternde Änderung macht einen Typ permissiver, etwa durch das Hinzufügen eines optionalen Feldes zu einem Interface oder das Erweitern einer Union um einen zusätzlichen Fall. Solche Änderungen sind in der Regel abwärtskompatibel, weil bestehender Code, der mit dem engeren Typ funktioniert hat, auch mit dem breiteren Typ funktioniert.
Eine einschränkende Änderung macht einen Typ dagegen restriktiver, etwa durch das Entfernen eines Union-Falls, das Hinzufügen eines Pflichtfelds oder das Verschärfen einer Funktionssignatur. Solche Änderungen brechen fast immer bestehenden Code, selbst wenn die Einschränkung inhaltlich sinnvoll und längst überfällig war. Die Tücke: bei Funktionsparametern kehrt sich diese Regel wegen der kontravarianten Typprüfung von Parametern manchmal um, weshalb jede Änderung an öffentlichen Funktionssignaturen einer TypeScript-Bibliothek einzeln geprüft werden sollte, statt sich auf eine pauschale Regel zu verlassen.
// Extending: adding an optional field — backwards compatible
interface QueryOptions {
limit?: number;
offset?: number;
// NEW in v1.4.0 — optional, existing callers are unaffected
timeoutMs?: number;
}
// Narrowing: removing a union case — breaking change
// BEFORE (v1.x): type SortDirection = "asc" | "desc" | "none";
type SortDirection = "asc" | "desc"; // "none" removed — v2.0 only
3. Deprecation-Strategie mit @deprecated statt sofortigem Entfernen
Statt eine veraltete Funktion oder einen veralteten Typ sofort zu entfernen, markiert eine gut gepflegte TypeScript-Bibliothek sie zunächst mit dem JSDoc-Tag @deprecated. IDEs wie VS Code und WebStorm zeigen daraufhin einen durchgestrichenen Namen an jeder Verwendungsstelle an, ohne den Code lauffähig zu unterbrechen. Konsumenten bekommen so eine sichtbare, aber nicht erzwingende Warnung, dass eine Migration ansteht, mit ausreichend Vorlaufzeit vor dem eigentlichen Entfernen im nächsten Major-Release.
Wichtig ist, im @deprecated-Kommentar konkret zu beschreiben, welche Alternative stattdessen verwendet werden soll, nicht nur, dass etwas veraltet ist. Ein Kommentar wie @deprecated Use createQueryBuilder() instead, will be removed in v3.0 gibt Konsumenten eine klare Handlungsanweisung und eine grobe zeitliche Einordnung, wann die alte API tatsächlich verschwindet.
// query-builder.ts — deprecated but still functional
export class QueryBuilder {
/**
* @deprecated Use `where(conditions: WhereClause[])` instead.
* Will be removed in v3.0. This overload silently ignored
* operator precedence for more than two conditions.
*/
where(field: string, operator: string, value: unknown): this;
where(conditions: WhereClause[]): this;
where(fieldOrConditions: string | WhereClause[], operator?: string, value?: unknown): this {
// Implementation dispatches to the new logic either way
const conditions = Array.isArray(fieldOrConditions)
? fieldOrConditions
: [{ field: fieldOrConditions, operator: operator!, value }];
this.applyConditions(conditions);
return this;
}
private applyConditions(conditions: WhereClause[]): void {
// ...
}
}
4. Overloads nutzen um alte und neue Signaturen zu unterstützen
Funktions-Overloads sind das wichtigste technische Werkzeug, um alte und neue Aufrufsignaturen einer TypeScript-Bibliothek gleichzeitig anzubieten, ohne eine der beiden Varianten sofort zu entfernen. Mehrere Overload-Signaturen definieren, welche Kombinationen von Parametertypen gültig sind, während eine einzige Implementierungssignatur intern beide Fälle behandelt. Für Konsumenten sieht es so aus, als gäbe es zwei unabhängige Funktionen, tatsächlich teilen sie sich dieselbe Implementierung.
Der Vorteil gegenüber einer einzigen, breiten Union-Signatur wie where(a: string | WhereClause[], b?: string, c?: unknown) ist die Typsicherheit an der Aufrufstelle: TypeScript erlaubt bei Overloads ausschließlich die exakt deklarierten Kombinationen, während eine einzige Union-Signatur unerwünschte Mischformen wie where(clauseArray, "eq") nicht automatisch verhindert. Overloads sind deshalb die präzisere, aber auch aufwendigere Lösung für Backwards Compatibility bei Signaturänderungen.
5. API-Reports zur automatisierten Erkennung von Breaking Changes
Manuelles Review jeder Typänderung skaliert nicht, sobald eine TypeScript-Bibliothek mehrere hundert exportierte Symbole umfasst. Tools wie API Extractor generieren einen sogenannten API-Report, eine kompakte Textdatei mit der vollständigen öffentlichen Signatur der Bibliothek. Diese Datei wird als Snapshot im Repository eingecheckt und bei jedem Pull Request automatisch neu generiert und mit der eingecheckten Version verglichen.
Weicht der neu generierte Report vom eingecheckten ab, schlägt der CI-Check fehl, und der Pull Request muss den Report bewusst aktualisieren, bevor er gemergt werden kann. Dieser erzwungene Zwischenschritt macht jede Typänderung an der öffentlichen API sichtbar, selbst wenn sie beiläufig als Nebeneffekt eines internen Refactorings entstanden ist. Ohne API-Report bleiben solche unbeabsichtigten Änderungen an der öffentlichen Signatur oft bis zum ersten Konsumenten-Bugreport unentdeckt.
# CI step: fails the build if the public API drifted
# without a deliberate, reviewed report update
npx api-extractor run --local --verbose
# Diff shown in CI when a signature changed unexpectedly
# - export declare function where(field: string, operator: string, value: unknown): QueryBuilder;
# + export declare function where(field: string, operator: string, value: string): QueryBuilder;
6. SemVer für Typen: wann Major, Minor, Patch
Semantic Versioning gilt für eine TypeScript-Bibliothek nicht nur für Laufzeitverhalten, sondern gleichwertig für die öffentliche Typoberfläche. Ein Patch-Release ändert weder Typen noch Verhalten sichtbar, ein reiner Bugfix ohne Signaturänderung. Ein Minor-Release erweitert die API additiv: neue exportierte Funktionen, neue optionale Parameter, neue Union-Fälle, die bestehenden Code nicht brechen. Ein Major-Release entfernt oder verengt etwas an der öffentlichen Typoberfläche, unabhängig davon, ob sich das Laufzeitverhalten überhaupt ändert.
Die Praxis zeigt, dass Teams diese Regel häufig zu lax auslegen und eine Typkorrektur, die vorher zu lax war, fälschlich als Patch-Release veröffentlichen. Ein bewährter Test: die Änderung testweise gegen eine Sammlung repräsentativer Konsumenten-Codeausschnitte kompilieren. Bricht auch nur einer dieser Ausschnitte, handelt es sich unabhängig von der ursprünglichen Absicht um einen Major-Release im Sinne von SemVer.
# .changeset/curious-lions-argue.md — describes the release type
# generated by "npx changeset" and reviewed before merge
---
"@mironsoft/query-builder": major
---
BREAKING: `where(field, operator, value)` overload removed in favor
of `where(conditions: WhereClause[])`. See MIGRATION.md for the codemod.
7. Migration Guides und Codemods für Konsumenten
Ein Major-Release, das eine Typänderung tatsächlich vollzieht, sollte immer von einem Migration Guide begleitet werden, der die konkreten Vorher-Nachher-Beispiele zeigt, nicht nur abstrakt beschreibt, was sich geändert hat. Konsumenten wollen in Sekunden erkennen, ob und wie ihr Code angepasst werden muss, ohne den gesamten Changelog zu lesen. Ein guter Migration Guide listet jede Breaking Change einzeln mit einem kurzen Vorher-Nachher-Codebeispiel.
Für größere TypeScript-Bibliotheken mit vielen Konsumenten lohnt sich zusätzlich ein Codemod, ein automatisiertes Skript auf Basis von ts-morph oder jscodeshift, das den Konsumenten-Code automatisch von der alten auf die neue API umschreibt. Ein Codemod reduziert den manuellen Migrationsaufwand erheblich und senkt die Hemmschwelle für Konsumenten, ein Major-Release überhaupt zu übernehmen, statt dauerhaft auf einer alten Version zu verharren.
// codemod.ts — ts-morph script rewriting the old call signature
import { Project, SyntaxKind } from "ts-morph";
const project = new Project();
project.addSourceFilesAtPaths("src/**/*.ts");
for (const file of project.getSourceFiles()) {
const calls = file.getDescendantsOfKind(SyntaxKind.CallExpression);
for (const call of calls) {
const expr = call.getExpression().getText();
if (expr.endsWith(".where") && call.getArguments().length === 3) {
// Rewrite where(field, operator, value) to where([{ field, operator, value }])
const [field, operator, value] = call.getArguments().map((a) => a.getText());
call.replaceWithText(
`${expr}([{ field: ${field}, operator: ${operator}, value: ${value} }])`
);
}
}
}
project.saveSync();
8. Testing: Type-Level-Tests gegen Konsumenten-Codebasen
Neben klassischen Laufzeittests braucht eine TypeScript-Bibliothek Type-Level-Tests, die ausschließlich prüfen, ob bestimmte Typausdrücke kompilieren oder eben nicht kompilieren sollten. Tools wie tsd oder expect-type erlauben, genau dieses Verhalten in Testdateien festzuhalten: ein expectError()-Block markiert Code, der absichtlich einen Compile-Fehler auslösen soll, ein expectType()-Block prüft, dass ein Ausdruck exakt den erwarteten Typ hat.
Für besonders kritische Konsumenten, meist die größten oder wichtigsten, lohnt sich zusätzlich ein sogenannter Consumer-Test: ein minimaler, repräsentativer Codeausschnitt aus der echten Konsumenten-Codebasis wird als eigenständiger Test in die CI-Pipeline der Bibliothek aufgenommen. Bricht dieser Ausschnitt beim Kompilieren, verhindert das den Merge der Änderung, bevor sie überhaupt veröffentlicht wird, statt erst nach einer Support-Anfrage reagieren zu müssen.
9. Strategien für Typänderungen im Vergleich
Je nach Art der geplanten Typänderung eignen sich unterschiedliche Strategien, um Backwards Compatibility zu wahren oder den Bruch kontrolliert zu kommunizieren.
| Änderungsart | Strategie | Release-Typ |
|---|---|---|
| Neue optionale Eigenschaft | Direkt hinzufügen | Minor |
| Alte Funktion ablösen | @deprecated, dann entfernen | Minor, dann Major |
| Signatur ändern | Overloads für Übergang | Minor, dann Major |
| Pflichtfeld einführen | Sofortiger Bruch, kein Umweg | Major mit Migration Guide |
Der gemeinsame Nenner aller vier Zeilen: je größer der potenzielle Einfluss auf Konsumenten, desto mehr Vorlaufzeit und Kommunikation braucht die Änderung, bevor sie tatsächlich als Breaking Change ausgeliefert wird.
Mironsoft
TypeScript-Bibliotheken, API-Design und Release-Prozesse
Breaking Changes in eurer TypeScript-Bibliothek vermeiden?
Wir richten API-Reports, Deprecation-Prozesse und SemVer-konforme Release-Pipelines ein, damit Typänderungen kontrolliert und ohne Überraschungen bei Konsumenten ankommen.
API-Report-Setup
Automatisierte Erkennung unbeabsichtigter Typänderungen in der CI-Pipeline
Migration-Tooling
Codemods und Migration Guides für reibungslose Major-Releases
SemVer-Beratung
Release-Klassifizierung für Typänderungen sauber definieren
10. Zusammenfassung
Backwards Compatibility bei Typ-Änderungen bedeutet, die öffentliche Typoberfläche einer TypeScript-Bibliothek genauso ernst zu nehmen wie deren Laufzeitverhalten. Overloads erlauben es, alte und neue Aufrufsignaturen parallel zu unterstützen, @deprecated-Kommentare geben Konsumenten Vorlaufzeit statt sofortiger Brüche, und API-Reports machen jede Änderung an der öffentlichen API sichtbar, bevor sie ungewollt in ein Release gelangt.
Semantic Versioning muss für Typen genauso konsequent angewendet werden wie für Laufzeitverhalten, denn ein Compile-Fehler beim Konsumenten ist für dessen Team ebenso ein Ausfall wie ein Laufzeitfehler. Migration Guides und Codemods senken die Hemmschwelle, ein notwendiges Major-Release tatsächlich zu übernehmen, statt dauerhaft auf einer veralteten Version zu verharren.
Backwards Compatibility bei Typ-Änderungen — Das Wichtigste auf einen Blick
Breaking Changes erkennen
Auch reine Typänderungen ohne Laufzeit-Effekt können bestehenden Code zum Compile-Fehler machen.
Übergangsstrategien
Overloads und @deprecated statt sofortigem Entfernen alter Signaturen.
Automatisierte Kontrolle
API-Reports und Type-Level-Tests decken unbeabsichtigte Typänderungen vor dem Release auf.
SemVer & Migration
Konsequentes SemVer für Typen, begleitet von Migration Guides und Codemods für Konsumenten.