Unveränderlichkeit ist eine der stillen Säulen verlässlicher TypeScript-Codebasen — wer Datenflüsse als read-only modelliert, eliminiert eine ganze Klasse von Bugs, bevor sie überhaupt entstehen. TypeScript bietet drei eigenständige Werkzeuge dafür: den readonly-Property-Modifier, das Readonly<T>-Utility und die as const-Assertion — jedes mit eigener Reichweite, eigenem Anwendungsfeld und eigenen Grenzen. Dieser Artikel zeigt, welcher Mechanismus wann greift, warum keiner davon zur Laufzeit schützt und wie das Deep-Readonly-Pattern über Mapped & Conditional Types tief verschachtelte Strukturen abdichtet. Wer die drei Werkzeuge sauber trennt, schreibt APIs, die ihre Versprechen einlösen — und Konfigurations-Objekte, die nicht versehentlich aus zehn Funktionen heraus mutiert werden.
Drei Wege zu Unveränderlichkeit
Bevor wir die einzelnen Mechanismen aufschlüsseln, ein kompakter Vergleich der drei Werkzeuge — sie überlappen, sind aber nicht austauschbar.
| Werkzeug | Anwendung | Wirkung auf Werte | Wirkung auf Mutability | Tiefe |
|---|---|---|---|---|
readonly (Modifier) | Properties in interface/type/class | keine | Property nicht zuweisbar | flach |
Readonly<T> (Utility) | bestehender Typ → Wrapper | keine | alle Properties readonly | flach |
as const (Assertion) | Literal-Ausdruck im Code | Literal Types, keine Widening | alle Properties readonly, Arrays → readonly Tuples | flach (Properties), aber tief bei verschachtelten Literalen |
Gemeinsamer Nenner: Alle drei sind reine Compile-Zeit-Konstrukte — der erzeugte JavaScript-Code enthält keine einzige Schutzmaßnahme. Für echten Runtime-Schutz braucht es Object.freeze (Abschnitt 09).
readonly als Property-Modifier
Der direkteste Weg: readonly vor einer Property-Deklaration sagt dem Compiler, dass die Property nach Initialisierung nicht mehr zugewiesen werden darf. Funktioniert in interface, type und class.
interface Config {
readonly apiUrl: string;
readonly timeout?: number;
maxRetries: number; // bewusst mutable
}
function configure(c: Config) {
c.apiUrl = "/v2"; // Error: Cannot assign to 'apiUrl' (read-only).
c.maxRetries = 5; // + OK — nicht readonly
}In einer class darf eine readonly-Property nur im Konstruktor oder bei der Felddeklaration gesetzt werden. Das macht sie zum idiomatischen Pendant zu Javas final oder C#s readonly.
class User {
readonly id: string;
readonly createdAt = new Date();
constructor(id: string) {
this.id = id; // + OK — Konstruktor
}
rename(newId: string) {
this.id = newId; // ! Error — auch innerhalb der Klasse
}
}Wichtig: readonly ist flach. Eine readonly-Property, die auf ein Objekt zeigt, schützt nur die Referenz — die Felder des Zielobjekts bleiben frei mutierbar.
interface Home {
readonly resident: { name: string; age: number };
}
function birthday(home: Home) {
home.resident.age++; // + OK — nested Mutation
home.resident = { name: "x", age: 0 }; // ! Error — Reassignment
}Readonly<T> — das Utility
Readonly<T> ist ein Built-in Mapped Type, der jeden Property-Schlüssel eines Typs mit dem readonly-Modifier versieht. Praktisch, wenn man einen bestehenden, mutable definierten Typ punktuell als unveränderlich verwenden will.
interface Todo {
title: string;
completed: boolean;
}
function display(todo: Readonly<Todo>) {
todo.completed = true;
// ^^^^^^^^^ Error: Cannot assign to 'completed' (read-only).
}Die Definition im lib.d.ts ist trivial — und perfekt zum Einprägen, weil sie das Mapped-Type-Muster im Mini-Format zeigt:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};Readonly<T> ist exakt so flach wie der Modifier selbst: verschachtelte Properties bleiben mutierbar. Wer komplette Objekt-Bäume einfrieren will, braucht das Deep-Readonly-Pattern aus Abschnitt 07.
ReadonlyArray<T> und readonly T[]
Arrays haben in TypeScript einen eigenen, schlankeren Readonly-Typ — und zwei austauschbare Schreibweisen für identisches Verhalten.
const a: ReadonlyArray<number> = [1, 2, 3];
const b: readonly number[] = [1, 2, 3]; // identisch
a.push(4); // ! Error — Property 'push' does not exist on 'readonly number[]'
a[0] = 99; // ! Error — Index signature in type ... only permits readingWas fehlt: alle mutierenden Methoden — push, pop, shift, unshift, splice, sort, reverse, fill, copyWithin. Verfügbar bleibt alles Lesende und Nicht-Mutierende: map, filter, reduce, find, slice, concat, includes, indexOf, forEach, Iteration mit for ... of.
Die Zuweisbarkeit ist einseitig — und genau das macht readonly-Arrays als Funktions-Parameter so wertvoll:
let mut: number[] = [1, 2, 3];
let ro: readonly number[] = [1, 2, 3];
ro = mut; // + OK — strenger Typ akzeptiert lockereren Input
mut = ro; // ! Error — readonly kann nicht in mutable fließenFür Tuple gilt dieselbe Logik mit eigener Syntax: readonly [string, number] ist ein Tuple, dessen Elemente nicht überschrieben werden können.
as const — die Const-Assertion
Eingeführt in TypeScript 3.4, ist as const eine Type-Assertion eigener Art — keine Cast-Operation auf einen konkreten Typ, sondern eine Anweisung an den Compiler, drei Verschärfungen gleichzeitig anzuwenden:
- Keine Literal-Widening —
"foo"bleibt"foo", nichtstring. - Object-Literale werden komplett
readonly— auch verschachtelt. - Array-Literale werden zu
readonlyTuples — mit Literal-Typen pro Position.
// Ohne as const:
const x1 = "hello"; // Type: "hello" (const-Variable, aber widened bei Übergabe)
const y1 = [10, 20]; // Type: number[]
const z1 = { text: "hi" }; // Type: { text: string }
// Mit as const:
const x2 = "hello" as const; // Type: "hello"
const y2 = [10, 20] as const; // Type: readonly [10, 20]
const z2 = { text: "hi" } as const; // Type: { readonly text: "hi" }Besonders wirkungsvoll ist as const bei verschachtelten Literalen — anders als Readonly<T> wirkt es strukturell rekursiv, weil jeder eingebettete Literal-Ausdruck mit-asserted wird:
const theme = {
colors: {
primary: "#1a1a1a",
accent: "#c08",
},
spacing: [4, 8, 16, 32],
} as const;
// Type:
// {
// readonly colors: {
// readonly primary: "#1a1a1a";
// readonly accent: "#c08";
// };
// readonly spacing: readonly [4, 8, 16, 32];
// }Caveat: as const funktioniert nur auf Literal-Ausdrücken, nicht auf berechneten Werten. (Math.random() < 0.5 ? 0 : 1) as const ist ein Fehler — die einzelnen Literale müssen jeweils selbst asserted werden.
as const vs. Readonly<T> im direkten Vergleich
Beide machen Properties readonly — aber as const tut deutlich mehr. Der Unterschied liegt in der Wertverengung.
// Variante A: Readonly<T>
const cfgA: Readonly<{ env: string; port: number }> = {
env: "prod",
port: 8080,
};
// cfgA.env: string — beliebiger String erlaubt beim Zuweisen anderswo
// Variante B: as const
const cfgB = {
env: "prod",
port: 8080,
} as const;
// cfgB.env: "prod" — Literal-Typ
// cfgB.port: 8080 — Literal-TypReadonly<T> sperrt die Mutation, lässt aber die Typ-Weite unangetastet. as const sperrt die Mutation und verengt die Typen auf ihre konkreten Literal-Werte. Das ist genau das, was man für Discriminated Unions, Lookup-Tabellen und enum-artige Konstanten-Objekte braucht:
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number]; // "admin" | "editor" | "viewer"
const STATUS = {
IDLE: "idle",
LOADING: "loading",
ERROR: "error",
} as const;
type Status = typeof STATUS[keyof typeof STATUS]; // "idle" | "loading" | "error"Dieses Muster ersetzt klassisch enum in den meisten modernen Codebasen — typesicherer, tree-shake-bar, ohne Laufzeit-Overhead.
Deep Readonly über Mapped & Conditional Types
Readonly<T> ist flach. as const wirkt zwar tief, ist aber an Literal-Ausdrücke gebunden — auf einen bestehenden Typ lässt es sich nicht anwenden. Für die Lücke schreibt man ein eigenes DeepReadonly als rekursiven Mapped Type mit Conditional-Type-Verzweigung.
type DeepReadonly<T> =
T extends (infer U)[] ? ReadonlyArray<DeepReadonly<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepReadonly<U>>
: T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends Set<infer U> ? ReadonlySet<DeepReadonly<U>>
: T extends (...args: any[]) => any ? T
: T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;Wie es liest:
- Arrays werden zu
ReadonlyArray<DeepReadonly<U>>umgewickelt. MapundSetbekommen ihre Readonly-Pendants.- Funktionen bleiben unverändert (sonst würde der Mapped Type ihre Call-Signatur zerlegen).
- Echte Objekte werden Property-für-Property rekursiv durchgemappt.
- Primitive Typen fallen am Ende durch.
interface AppState {
user: { name: string; tags: string[] };
history: { url: string; at: Date }[];
}
function reducer(state: DeepReadonly<AppState>) {
state.user.name = "x"; // ! Error
state.user.tags.push("new"); // ! Error
state.history[0].url = "/"; // ! Error
}In großen Codebasen lohnt sich die Auslagerung in eine Utility-Datei oder die Übernahme aus Bibliotheken wie type-fest (ReadonlyDeep) oder ts-toolbelt (Object.Readonly mit Tiefen-Parameter).
Praxis-Use-Cases
Drei Muster, in denen sich readonly täglich auszahlt:
Funktions-Parameter abdichten. Markiere Array- und Objekt-Parameter als readonly, wenn die Funktion nicht mutieren soll. Der Compiler erinnert dich an unbeabsichtigte Inplace-Operationen — und Aufrufer sehen sofort, dass ihre Daten unverändert zurückkommen.
function sum(values: readonly number[]): number {
return values.reduce((a, b) => a + b, 0);
}
function rename(u: Readonly<User>, name: string): User {
return { ...u, name }; // neues Objekt, kein Mutation
}Konfigurations-Objekte und Konstanten. Theme-Tokens, Feature-Flags, Routen-Tabellen — alles, was zur Build-Zeit feststeht und in vielen Modulen gelesen wird, profitiert massiv von as const. Versehentliche Schreibversuche werden zum Compile-Fehler, Literal-Typen erlauben präzise Autocomplete-Vorschläge.
export const ROUTES = {
home: "/",
login: "/login",
dashboard:"/dashboard",
} as const;
type RoutePath = typeof ROUTES[keyof typeof ROUTES];
// RoutePath = "/" | "/login" | "/dashboard"React-Props und Redux-State. Props sind in React per Konvention unveränderlich — sie mit Readonly<P> (oder DeepReadonly<P>) zu typen macht diese Konvention für den Compiler sichtbar. Redux-State unter DeepReadonly zwingt Reducer dazu, neue Objekte zurückzugeben — kein versehentliches state.user.name = ... mehr.
Runtime-Schutz mit Object.freeze
Alle drei Mechanismen — readonly, Readonly<T>, as const — sind reine Compile-Zeit-Konstrukte. Im transpilierten JavaScript existieren sie nicht. Wer Schreibversuche zur Laufzeit verhindern will, kombiniert die Typ-Markierung mit Object.freeze.
const config = Object.freeze({
apiUrl: "/api",
timeout: 5000,
});
config.apiUrl = "/v2";
// TS: Error (Object.freeze gibt Readonly<T> zurück)
// JS strict: TypeError — Cannot assign to read only property 'apiUrl'
// JS non-strict: still leise!Object.freeze ist flach wie Readonly<T> — geschachtelte Objekte bleiben mutierbar, bis man rekursiv friert. Für ein echtes Deep-Freeze ist ein kleiner Helper nötig:
function deepFreeze<T>(obj: T): DeepReadonly<T> {
if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
Object.values(obj as object).forEach(deepFreeze);
Object.freeze(obj);
}
return obj as DeepReadonly<T>;
}Wann wirklich freeze? In Produktiv-Hot-Pfaden eher selten — der Performance-Overhead ist gering, aber nicht null, und der Compiler fängt 99 % der Fehler ohnehin ab. Lohnt sich vor allem für public APIs von Bibliotheken, exportierte Singletons und Test-Fixtures, in denen versehentliche Mutation ein gefundenes Fressen für Heisenbugs ist.
Besonderheiten
readonly ist Compile-Zeit-only — (obj as any).prop = ... umgeht es.
Der Modifier existiert nur im Typsystem. Im transpilierten JavaScript gibt es keine Spur davon. Ein as any-Cast, ein @ts-ignore oder eine indirekte Zuweisung durch eine mutable Referenz umgeht den Schutz vollständig. Wer echte Laufzeit-Garantien braucht, kombiniert die Typ-Markierung mit Object.freeze — und akzeptiert, dass auch das nur flach wirkt.
as const macht Object-Literale deep-readonly UND verengt Werte.
Anders als Readonly<T>, das nur den Mutability-Modifier hinzufügt, wirkt as const doppelt: Es markiert alle Properties readonly und friert gleichzeitig jeden Literal-Wert auf seinen exakten Typ ein. Aus { env: "prod" } wird nicht { readonly env: string }, sondern { readonly env: "prod" } — und genau das macht das Muster für Discriminated Unions unverzichtbar.
Readonly ist flach — verschachtelte Properties bleiben mutable.
Das Utility wickelt nur die Top-Level-Properties in readonly ein. Ein Readonly<{ user: { name: string } }> verhindert obj.user = ..., lässt aber obj.user.name = "x" passieren. Wer das nicht will, schreibt eine eigene DeepReadonly-Utility oder greift zu type-fests ReadonlyDeep.
ReadonlyArray fehlen push/sort/splice — map/filter/reduce funktionieren.
Der readonly T[]-Typ kennt nur die nicht-mutierenden Methoden. Alles, was einen neuen Array zurückgibt (map, filter, slice, concat) oder nur liest (find, includes, reduce), ist verfügbar. Inplace-Operationen wie push, pop, sort, reverse, splice, fill fehlen vollständig — und das ist genau der Sinn.
Bei Funktions-Parametern mit ReadonlyArray darf der Aufrufer trotzdem mutable Arrays übergeben.
Die Zuweisbarkeit ist einseitig: mutable ist zu readonly kompatibel, umgekehrt nicht. Das ist erwünscht — eine Funktion, die nur lesen will, sollte beide Varianten akzeptieren. Erst wenn die Funktion ihre Eingabe weiterreicht an etwas Mutierendes, wird der Compiler streng.
const-Variable vs. readonly-Property — unterschiedliche Konzepte.
const x = obj verhindert die Neuzuweisung der Variable (x = anderes), nicht aber die Mutation des referenzierten Objekts (x.foo = ...). readonly verhindert genau das Gegenteil: die Mutation der Property, nicht die Reassignment der Variable, die das Objekt hält. Beide Modifier zusammen ergeben erst echte Property-Stabilität.
as const erzeugt out-of-the-box Discriminated-Union-fähige Daten.
Ein Array von Objekt-Literalen mit as const wird zu einem readonly Tuple, dessen Elemente jeweils eigene Literal-Typen tragen. Damit funktioniert das klassische kind-Pattern für Discriminated Unions ohne explizite Typ-Annotation — der Compiler narrowt automatisch in switch- oder if-Zweigen.
readonly hat keinen Runtime-Overhead — Performance-Bedenken sind unbegründet.
Da alle drei Mechanismen zur Compile-Zeit verschwinden, sind sie zur Laufzeit kostenlos. Erst Object.freeze erzeugt minimalen Overhead (V8 muss die Frozen-Flag prüfen) — und selbst der ist in der Praxis vernachlässigbar. Es gibt keinen Grund, auf Typ-Ebene mit readonly zu sparen.
Weiterführende Ressourcen
Externe Quellen
- Readonly Properties – TypeScript Handbook
- TypeScript 3.4 Release Notes (as const)
- Readonly Utility – TypeScript Handbook