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.

WerkzeugAnwendungWirkung auf WerteWirkung auf MutabilityTiefe
readonly (Modifier)Properties in interface/type/classkeineProperty nicht zuweisbarflach
Readonly<T> (Utility)bestehender Typ → Wrapperkeinealle Properties readonlyflach
as const (Assertion)Literal-Ausdruck im CodeLiteral Types, keine Wideningalle Properties readonly, Arrays → readonly Tuplesflach (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.

ts
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.

ts
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.

ts
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.

ts
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:

ts
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.

ts
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 reading

Was fehlt: alle mutierenden Methodenpush, 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:

ts
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ßen

Fü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:

  1. Keine Literal-Widening"foo" bleibt "foo", nicht string.
  2. Object-Literale werden komplett readonly — auch verschachtelt.
  3. Array-Literale werden zu readonly Tuples — mit Literal-Typen pro Position.
ts
// 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:

ts
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.

ts
// 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-Typ

Readonly<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:

ts
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.

ts
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.
  • Map und Set bekommen 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.
ts
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.

ts
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.

ts
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.

ts
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:

ts
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

/ Weiter

Zurück zu Komplexe Typen

Zur Übersicht