Mutationsfehler bereits zur Kompilierzeit statt erst im Produktivbetrieb erkennen
Arrays gelten in JavaScript standardmäßig als veränderlich, was in größeren Codebasen zu subtilen Fehlern führt, wenn eine Funktion ein übergebenes Array unbeabsichtigt verändert. TypeScript bietet mit readonly Arrays und readonly Tupeln ein Werkzeug, um solche Mutationen bereits zur Kompilierzeit auszuschließen.
Inhaltsverzeichnis
- 1. Das Problem stillschweigender Mutation
- 2. Grundlagen: readonly T[] und ReadonlyArray
- 3. Readonly Tupel
- 4. Kombination mit as const und satisfies
- 5. readonly als Vertrag für Funktionsparameter
- 6. Nur flache Unveränderlichkeit
- 7. Vergleich zu Object.freeze zur Laufzeit
- 8. Best Practices im Projektalltag
- 9. Fallstricke im Alltag
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem stillschweigender Mutation
Wird ein Array an eine Funktion übergeben, kann diese Funktion es ohne jede Warnung verändern, etwa durch push, sort oder splice. Der Aufrufer merkt davon oft nichts, bis an anderer Stelle im Programm plötzlich unerwartete Werte auftauchen, weil das ursprüngliche Array in der Zwischenzeit verändert wurde.
Solche Fehler sind besonders tückisch, weil sie sich erst zur Laufzeit zeigen und oft weit entfernt von der eigentlichen Ursache. TypeScript kann dieses Problem bereits zur Kompilierzeit verhindern, wenn die Absicht der Unveränderlichkeit im Typsystem festgehalten wird.
Besonders in asynchronem Code verschärft sich das Problem: Wird ein Array an mehrere parallel laufende Funktionen weitergereicht, kann eine unbemerkte Mutation in einer Funktion zu Race Conditions führen, die sich nur schwer reproduzieren lassen und in Tests oft unentdeckt bleiben.
2. Grundlagen: readonly T[] und ReadonlyArray
Ein Array-Typ kann mit dem readonly Modifier versehen werden, entweder als readonly T[] oder äquivalent als ReadonlyArray<T>. Beide Schreibweisen sind funktional identisch, readonly T[] ist lediglich die kürzere Syntax.
Sobald ein Array als readonly typisiert ist, blendet der Compiler alle mutierenden Methoden wie push, pop, splice oder sort aus. Ein Zugriffsversuch auf eine dieser Methoden führt zu einem Kompilierfehler, lesende Methoden wie map, filter oder slice bleiben dagegen weiterhin verfügbar.
Der Editor unterstützt diese Prüfung bereits während des Tippens: Sobald eine Variable als readonly Array erkannt wird, listet die Autovervollständigung nur noch die tatsächlich verfügbaren, nicht mutierenden Methoden auf.
function printAll(items: readonly string[]) {
console.log(items.join(", "));
items.push("neu"); // Fehler: push existiert nicht auf readonly string[]
}
const list: ReadonlyArray<number> = [1, 2, 3];
const doubled = list.map((n) => n * 2); // erlaubt, erzeugt neues Array
3. Readonly Tupel
Auch Tupel, also Arrays mit fester Länge und festem Typ an jeder Position, lassen sich mit readonly versehen. Das ist besonders wichtig, da Tupel oft für Koordinaten, Bereichsangaben oder Rückgabewerte mit mehreren Werten verwendet werden, bei denen eine versehentliche Umsortierung oder Längenänderung fatale Folgen hätte.
Ohne readonly erlaubt TypeScript bei einem normalen Tupel trotz fester Länge weiterhin Methoden wie push, was die feste Struktur zur Laufzeit unterlaufen kann. Ein readonly Tupel verhindert das zuverlässig.
Gerade bei Funktionen, die mehrere Werte als Tupel zurückgeben, etwa ein Ergebnis zusammen mit einem Fehlerobjekt nach dem Vorbild von Go, sorgt readonly zusätzlich dafür, dass der Aufrufer die Reihenfolge der Rückgabewerte nicht versehentlich vertauscht oder verändert.
function distance(a: readonly [number, number], b: readonly [number, number]) {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
const origin: readonly [number, number] = [0, 0];
// origin.push(5); // Fehler: push existiert nicht auf readonly Tupel
4. Kombination mit as const und satisfies
as const erzeugt für Array-Literale automatisch einen readonly Tupel-Typ mit den engsten Literal-Typen jeder Position. Diese Kombination ist besonders wertvoll für Konstanten, die niemals verändert werden sollen und deren exakte Werte im Typsystem erhalten bleiben sollen.
In Kombination mit satisfies lässt sich zusätzlich prüfen, ob ein solches unveränderliches Array-Literal zu einem erwarteten Typ passt, ohne die durch as const gewonnene Präzision zu verlieren.
Dieses Muster ersetzt in vielen Projekten vollständig die Notwendigkeit eines Enums für feste Listen von Werten, da sowohl die Unveränderlichkeit als auch die exakten Literal-Typen bereits über die Kombination aus as const und dem daraus abgeleiteten Union-Typ abgedeckt werden.
const weekdays = ["Mo", "Di", "Mi", "Do", "Fr"] as const;
// Typ: readonly ["Mo", "Di", "Mi", "Do", "Fr"]
type Weekday = (typeof weekdays)[number];
// Typ: "Mo" | "Di" | "Mi" | "Do" | "Fr"
5. readonly als Vertrag für Funktionsparameter
Ein bewährtes Muster ist, Funktionsparameter grundsätzlich als readonly zu deklarieren, wenn die Funktion das übergebene Array nicht verändern soll. Das macht die Absicht der Funktion für Aufrufer sofort ersichtlich und verhindert versehentliche Mutation innerhalb der Funktion selbst.
Praktischerweise akzeptiert eine Funktion, die einen readonly Array-Parameter erwartet, auch ganz normale, veränderliche Arrays als Argument, da ein veränderliches Array immer alle Fähigkeiten eines unveränderlichen Arrays besitzt. Der umgekehrte Fall funktioniert dagegen nicht.
In React- und ähnlichen State-Management-Kontexten ist dieses Muster besonders wertvoll, da Zustandswerte grundsätzlich unveränderlich behandelt werden sollten. readonly Parameter erzwingen diese Konvention bereits zur Kompilierzeit, statt sich allein auf Disziplin im Team zu verlassen.
function sum(values: readonly number[]): number {
return values.reduce((total, v) => total + v, 0);
}
const mutable = [1, 2, 3];
sum(mutable); // erlaubt, mutable[] ist kompatibel zu readonly number[]
6. Nur flache Unveränderlichkeit
Ein wichtiger Grenzfall: readonly wirkt nur auf der obersten Ebene. Ein readonly Array von veränderlichen Objekten verhindert zwar push oder splice auf dem Array selbst, hindert aber niemanden daran, eine Eigenschaft eines enthaltenen Objekts zu verändern.
Für echte, tiefe Unveränderlichkeit über mehrere Verschachtelungsebenen hinweg braucht es entweder verschachtelte readonly Modifier auf jedem Objekttyp oder einen rekursiven DeepReadonly Utility-Typ, den viele Projekte selbst definieren.
interface Point {
x: number;
y: number;
}
const points: readonly Point[] = [{ x: 0, y: 0 }];
points[0].x = 5; // erlaubt, da nur das Array selbst readonly ist
// points.push({ x: 1, y: 1 }); // Fehler
7. Vergleich zu Object.freeze zur Laufzeit
readonly ist ein reines Compile-Time-Feature ohne jede Laufzeit-Prüfung. Wird ein readonly typisiertes Array mit as any oder einer anderen Type Assertion umgangen, lässt sich der Inhalt trotzdem verändern, ohne dass zur Laufzeit ein Fehler auftritt.
Object.freeze bewirkt dagegen tatsächlichen Schutz zur Laufzeit: Ein Mutationsversuch schlägt im strict mode mit einer Exception fehl oder wird im nicht-strikten Modus stillschweigend ignoriert. Für maximale Sicherheit lassen sich beide Techniken kombinieren, readonly für die statische Prüfung während der Entwicklung, Object.freeze für den tatsächlichen Schutz zur Laufzeit.
const config = Object.freeze(["a", "b", "c"]) as readonly string[];
// Kompilierzeit-Schutz durch readonly
// Laufzeit-Schutz durch Object.freeze
8. Best Practices im Projektalltag
Für öffentliche Funktionssignaturen, insbesondere in Bibliotheken und geteilten Utility-Modulen, lohnt es sich, Array-Parameter standardmäßig als readonly zu deklarieren, sofern die Funktion sie nicht tatsächlich verändern muss. Das erhöht die Flexibilität für Aufrufer und dokumentiert gleichzeitig die Absicht.
Für exportierte Konstanten, insbesondere Konfigurationslisten oder Lookup-Tabellen, ist die Kombination aus as const und einem daraus abgeleiteten Union-Typ das robusteste Muster, da es sowohl Unveränderlichkeit als auch maximale Typpräzision liefert.
9. Fallstricke im Alltag
Ein häufiger Fehler ist die Annahme, ein readonly Array biete auch zur Laufzeit Schutz. Ohne zusätzliches Object.freeze lässt sich der zugrunde liegende Array-Wert weiterhin durch jeden Code verändern, der nicht durch den TypeScript-Compiler geprüft wird, etwa externe JavaScript-Bibliotheken oder Type Assertions.
Ein zweiter Stolperstein betrifft die Zuweisungsrichtung: Ein veränderliches Array kann problemlos einer readonly Variable zugewiesen werden, der umgekehrte Weg von readonly zu veränderlich erfordert dagegen eine explizite Type Assertion, da der Compiler diese Richtung als potenziell unsicher einstuft.
Ein dritter, seltener beachteter Punkt betrifft Bibliotheken mit eigenen, älteren Typdefinitionen, die Array-Parameter ohne readonly deklarieren, obwohl sie das Array intern nicht verändern. In solchen Fällen kann die Übergabe eines readonly Arrays trotz korrekten Laufzeitverhaltens einen Kompilierfehler auslösen, der nur durch eine lokale Type Assertion umgangen werden kann.
Ein letzter Hinweis betrifft Performance-kritischen Code: readonly selbst hat keinerlei Laufzeit-Kosten, da der Modifier vollständig beim Kompilieren entfernt wird. Bedenken bezüglich zusätzlichem Overhead durch die Verwendung von readonly sind daher in jedem Fall unbegründet und sollten kein Hindernis für die konsequente Nutzung darstellen.
| Merkmal | Normales Array | readonly Array | Object.freeze Array |
|---|---|---|---|
| Mutierende Methoden im Compiler sichtbar | Ja | Nein | Ja, aber wirkungslos |
| Laufzeit-Schutz vor Mutation | Nein | Nein | Ja |
| Kompilierzeit-Schutz vor Mutation | Nein | Ja | Nein |
| Kompatibel als Argument für readonly-Parameter | Ja | Ja | Ja |
| Tiefe der Unveränderlichkeit | Keine | Nur oberste Ebene | Nur oberste Ebene |
Mironsoft
TypeScript-Migration, Typsicherheit und Team-Onboarding
JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?
Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.
Migrations-Fahrplan
Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.
Strict-Mode-Einführung
tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.
Team-Onboarding
Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.
10. Zusammenfassung
Readonly Arrays und Tupel
Schutzebene
Nur zur Kompilierzeit
Kombinierbar mit
as const, satisfies, Object.freeze
Tiefe
Standardmäßig nur flach
Empfohlen für
Funktionsparameter, Konstanten