TypeScript 49 🔷 Immutability and Readonly Deep
Immutability is the practice of never modifying a value after it is created. Instead of changing an object, you create a new one with the change applied. This sounds like a small discipline, but it has large consequences: immutable data is safe to share, safe to compare by reference, safe to use in concurrent contexts, and easy to reason about because a value never changes underneath you. TypeScript supports immutability at several levels — readonly on properties, Readonly<T> for shallow protection, ReadonlyArray<T> for arrays, as const for literals, and deep readonly types for nested structures. The language cannot enforce immutability perfectly — readonly is a compile-time check, not a runtime one — but it catches the mistakes that matter and makes the intent explicit. This chapter covers the full range of TypeScript’s immutability features, from the shallow readonly modifier to deep readonly mapped types, and the tradeoffs that come with each.
Key point: readonly in TypeScript is a compile-time modifier, not a runtime guarantee. It prevents assignment to a property or array element, but Object.freeze is still needed to prevent mutation at runtime. Readonly<T> makes the top-level properties readonly, but nested objects remain mutable. A DeepReadonly<T> type recursively applies readonly at every level, and it is written with a recursive conditional mapped type. as const makes a literal value deeply readonly and infers the narrowest possible type — literal types for primitives, readonly tuples for arrays, and readonly properties for objects.
Why immutability matters
The case for immutability is not aesthetic. It is about eliminating a class of bugs and enabling a set of patterns that are unsafe with mutable data.
Shared references become safe. When two parts of an application hold the same object and neither can change it, there is no way for one to surprise the other. With mutable data, a function that receives an object may modify it, and the caller’s subsequent use of that object sees the change. This is the classic “action at a distance” bug, and immutability eliminates it.
Reference equality becomes meaningful. For mutable objects, a === b means “same object,” but not “same contents” — the contents may change. For immutable objects, a === b means the contents are equal, because neither can change. This is the basis of change detection in frameworks like Angular and React, which compare references to decide whether to re-render. With immutable data, the comparison is correct and cheap.
Undo and history become trivial. Keeping the previous version of an immutable object is just keeping a reference to it. It cannot be mutated out from under you. This is how undo stacks, time-travel debugging, and versioned state work.
Reasoning becomes local. A function that takes immutable data and returns immutable data can be understood by reading it alone. It cannot depend on hidden state, and it cannot have side effects on its inputs. This is the property that makes functional programming tractable.
Why the cost is real. Immutable updates allocate new objects, which is more expensive than mutating in place. For large data structures, the naive “copy everything” approach is untenable. This is why persistent data structures — where a copy shares most of its structure with the original — exist. In TypeScript, the common pattern is to copy shallowly and replace only the changed parts, which is cheap when the structure is shallow.
Why TypeScript’s
readonlyis a compile-time tool. The JavaScript runtime has no concept ofreadonly. The modifier exists only in the type system, and it is erased at compile time. This means areadonlyproperty can still be modified at runtime by code that bypasses the type system — a cast, aany, a plain JavaScript caller. The type system’s job is to catch the mistake at compile time, not to prevent it at runtime. For runtime protection,Object.freezeis the mechanism.
The readonly modifier
The simplest immutability tool is the readonly modifier on a property. It prevents assignment to that property after initialization.
interface Point {
readonly x: number;
readonly y: number;
}
const p: Point = { x: 1, y: 2 };
// p.x = 3; // ❌ Cannot assign to 'x' because it is a read-only property
The property is set when the object is created and cannot be reassigned. This is the shallow form of immutability — the property cannot be changed, but if the property’s value is itself an object, that object’s properties are not protected.
interface Config {
readonly server: { host: string; port: number };
}
const config: Config = { server: { host: "localhost", port: 8080 } };
config.server.host = "example.com"; // ✅ allowed — the nested property is not readonly
// config.server = { ... }; // ❌ the reference itself is readonly
The readonly modifier protects the reference, not the contents. This is the limitation that deep readonly types address.
Why readonly on a property is different from const. const applies to a variable binding — it prevents reassigning the variable. readonly applies to a property — it prevents assigning to that property of any object of that type. const x = { ... } prevents x = other, but x.field = value is still allowed unless field is readonly.
Why readonly does not prevent runtime mutation. The modifier is erased at compile time. A readonly property is a normal property at runtime, and any code that bypasses the type check can write to it. Object.freeze is the runtime mechanism, and it works by making the property non-writable at the descriptor level.
Why readonly is still worth using. It documents the intent and prevents accidental writes in typed code. A function that receives a readonly-typed parameter cannot accidentally reassign the property, and the compiler flags it if it tries. The protection is not absolute, but it catches the common case — the accidental assignment in the same codebase — without any runtime cost.
Readonly<T> and ReadonlyArray<T>
Readonly<T> is a mapped type that adds readonly to every property of T. It is shallow, like the readonly modifier, but applies to all top-level properties at once.
interface User {
id: string;
name: string;
email: string;
}
type ReadonlyUser = Readonly<User>;
// { readonly id: string; readonly name: string; readonly email: string }
const user: ReadonlyUser = { id: "1", name: "Alice", email: "alice@example.com" };
// user.name = "Bob"; // ❌
ReadonlyArray<T> is the array equivalent. It removes the mutating methods (push, pop, splice, sort, and so on) from the array’s type, leaving only the reading methods (map, filter, slice, forEach, and so on).
const nums: ReadonlyArray<number> = [1, 2, 3];
// nums.push(4); // ❌ Property 'push' does not exist on type 'readonly number[]'
const doubled = nums.map(n => n * 2); // ✅
The readonly T[] syntax is equivalent to ReadonlyArray<T>, and the two are interchangeable. The readonly modifier is preferred in modern TypeScript because it is shorter and composes naturally with other array syntax.
Why ReadonlyArray is important for variance. Mutable arrays are covariant in TypeScript, which is unsound. ReadonlyArray<T> is genuinely covariant because it has no mutating methods, so the covariance is safe. This is why function parameters that only read from an array should be typed readonly T[] — it is both more accurate and more accepting of subtypes.
Why Readonly<T> is shallow. The mapped type applies readonly to the immediate properties only. If a property’s value is an object, that object’s properties are not affected. This is by design — making a type deeply readonly is a separate operation with real costs, and the shallow version is often what is wanted.
Why shallow readonly is usually enough. Most code only needs to prevent the top-level reassignment. The nested case is rarer, and when it matters, a deep readonly type is the tool. Using the deep version everywhere is over-engineering.
Deep readonly
A deep readonly type recursively applies readonly to every level of a structure. It is written with a recursive conditional mapped type.
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
The type checks whether T is an array, in which case it makes the array readonly and recurses on the element type. Otherwise, if T is an object, it maps over the keys and applies DeepReadonly to each value. If T is a primitive, it returns T unchanged. The recursion terminates at the primitives.
interface Config {
server: {
host: string;
port: number;
ssl: { enabled: boolean; cert: string };
};
retries: number;
}
type FrozenConfig = DeepReadonly<Config>;
const config: FrozenConfig = {
server: {
host: "localhost",
port: 8080,
ssl: { enabled: true, cert: "/path/cert.pem" },
},
retries: 3,
};
// config.server.ssl.enabled = false; // ❌ readonly at every level
// config.server.host = "other"; // ❌
// config.retries = 5; // ❌
Every level is protected. The nested object’s properties are readonly, and the deeper nested object’s properties are readonly too.
Why the array check comes first. Arrays are objects in JavaScript, so the T extends object branch would match them. Putting the array check first handles arrays explicitly, ensuring that the array is typed as ReadonlyArray and the element type is recursed. Without the array check, arrays would be mapped as objects, which produces incorrect results.
Why functions and special types are edge cases. A function is an object, so the T extends object branch would match it and map over its properties, producing a type that is not what is wanted. A Date is an object, and mapping over it produces a plain object with all its methods readonly, losing the Date type. The simple DeepReadonly shown above does not handle these cases. A production-quality version would exclude functions and known types like Date, RegExp, Map, and Set.
Why deep readonly has a real cost. The recursive type is applied to every property of the structure, and the compiler must evaluate it. For a deeply nested type, the evaluation is expensive, and the type can become hard to display in the editor. The DeepReadonly type is also contagious — a function that receives a DeepReadonly<Config> cannot pass it to a function that expects Config, even if the function only reads. The conversion is one-way, and that can be inconvenient.
Why some libraries use DeepReadonly and others do not. Zustand, Redux, and Immer all deal with immutable state, and each handles the typing differently. Immer’s produce function returns a type that is logically the mutable version of the draft, and the readonly enforcement is at the boundary. The choice of how deep to enforce depends on the library’s model and the ergonomics it prioritizes.
as const assertions
The as const assertion is the most complete form of immutability at the type level. It makes a value deeply readonly and infers the narrowest possible type.
const config = {
host: "localhost",
port: 8080,
protocols: ["http", "https"],
} as const;
Without as const, the type of config is { host: string; port: number; protocols: string[] }. With as const, it is { readonly host: "localhost"; readonly port: 8080; readonly protocols: readonly ["http", "https"] }. The primitive values are inferred as their literal types, the object properties are readonly, and the array becomes a readonly tuple with literal element types.
Why the literal inference is the main benefit. Without as const, "localhost" is inferred as string, which means the type does not record which specific string it is. With as const, the type is the literal "localhost", which is more precise and enables the compiler to check the value against other types. This is why as const is used for constant tables, configuration objects, and discriminated union members.
Why as const is a shallow assertion syntactically but deep in effect. The assertion applies to the whole object literal. Syntactically it appears at the end of the expression, but semantically it makes every property and every nested element readonly and literal. It is the one-line way to get deep readonly plus literal inference.
Why as const is sometimes too much. The narrow literal types can be inconvenient when the value should be a general type. A const array of strings used as a list of allowed values is often wanted as a literal tuple for validation, but a const object used as a default config might be wanted as a general type so it can be spread and overridden. The choice is whether the precision is helpful or obstructive.
Why as const arrays become tuples. A const array literal is inferred as a readonly tuple with one element type per position. [1, 2, 3] as const has type readonly [1, 2, 3], not readonly number[]. This is more precise — the length and each element are known — and it is what enables as const to be used for enumerations and fixed-length data.
Runtime immutability with Object.freeze
The type system enforces immutability at compile time. The runtime enforces it with Object.freeze.
const config = Object.freeze({
host: "localhost",
port: 8080,
});
// config.port = 9090; // ✅ compiles (type is not readonly) but silently fails at runtime
Object.freeze makes the object’s properties non-writable at the descriptor level. An assignment to a frozen property in strict mode throws a TypeError; in sloppy mode it silently fails. Since ES modules and classes are strict by default, the throw is the common behavior.
Why Object.freeze and readonly are complementary. readonly catches mistakes in typed code at compile time. Object.freeze catches mistakes in untyped code at runtime. Neither alone is complete. Using both — the type for the developer, the freeze for the runtime — covers the cases that matter.
Why Object.freeze is shallow. Like readonly, Object.freeze only affects the top level. A nested object is still mutable unless it is also frozen. A deep freeze requires a recursive function that freezes each nested object, which is the runtime counterpart of DeepReadonly.
function deepFreeze<T>(obj: T): DeepReadonly<T> {
Object.freeze(obj);
for (const key of Object.keys(obj as object)) {
const value = (obj as Record<string, unknown>)[key];
if (value && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return obj as DeepReadonly<T>;
}
The function freezes the object and then recurses into every nested object. The return type is DeepReadonly<T>, which aligns the runtime state with the compile-time type.
Why Object.freeze has a performance cost. Freezing an object makes its properties non-writable, which prevents some JavaScript engine optimizations. For hot paths, freezing every object can measurably slow execution. The tradeoff is between the safety of immutability and the speed of mutable access. For configuration and state that changes rarely, the cost is negligible. For large data processed in tight loops, it is not.
Why the combination of type and runtime is the right approach. The type is documentation and a compile-time check. The freeze is a runtime guarantee. Each covers the other’s gap — the type cannot stop a JavaScript caller from mutating, and the freeze cannot give the developer a compile error. Using both is the complete answer, and it is the pattern that production libraries follow.
Complete Example Session
// ============================================
// PART 1: READONLY MODIFIER
// ============================================
interface Point {
readonly x: number;
readonly y: number;
}
const p: Point = { x: 1, y: 2 };
// p.x = 3; // ❌
// ============================================
// PART 2: SHALLOW READONLY
// ============================================
interface Config {
readonly server: { host: string; port: number };
}
const config: Config = { server: { host: "localhost", port: 8080 } };
config.server.host = "example.com"; // ✅ nested is mutable
// config.server = { host: "x", port: 1 }; // ❌ reference is readonly
// ============================================
// PART 3: Readonly<T>
// ============================================
interface User {
id: string;
name: string;
}
const user: Readonly<User> = { id: "1", name: "Alice" };
// user.name = "Bob"; // ❌
// ============================================
// PART 4: ReadonlyArray<T>
// ============================================
const nums: readonly number[] = [1, 2, 3];
// nums.push(4); // ❌
const doubled = nums.map(n => n * 2); // ✅
// ============================================
// PART 5: DEEP READONLY
// ============================================
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface NestedConfig {
server: {
host: string;
ssl: { enabled: boolean; cert: string };
};
}
const deep: DeepReadonly<NestedConfig> = {
server: { host: "localhost", ssl: { enabled: true, cert: "/cert" } },
};
// deep.server.ssl.enabled = false; // ❌
// deep.server.host = "x"; // ❌
// ============================================
// PART 6: AS CONST
// ============================================
const config2 = {
host: "localhost",
port: 8080,
protocols: ["http", "https"],
} as const;
// config2.host = "x"; // ❌ readonly
// config2.protocols.push("ftp"); // ❌ readonly array
// typeof config2.host // "localhost" (literal)
// ============================================
// PART 7: AS CONST FOR DISCRIMINATED UNIONS
// ============================================
const actions = [
{ type: "add", payload: 1 },
{ type: "remove", payload: 2 },
] as const;
type Action = typeof actions[number];
// { readonly type: "add"; readonly payload: 1 }
// | { readonly type: "remove"; readonly payload: 2 }
// ============================================
// PART 8: OBJECT.FREEZE FOR RUNTIME
// ============================================
const frozen = Object.freeze({ host: "localhost", port: 8080 });
// frozen.port = 9090; // TypeError in strict mode
// ============================================
// PART 9: DEEP FREEZE
// ============================================
function deepFreeze<T>(obj: T): DeepReadonly<T> {
Object.freeze(obj);
for (const key of Object.keys(obj as object)) {
const value = (obj as Record<string, unknown>)[key];
if (value && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return obj as DeepReadonly<T>;
}
const df = deepFreeze({ server: { host: "localhost" } });
// df.server.host = "x"; // TypeError at runtime, ❌ at compile time
// ============================================
// PART 10: IMMUTABLE UPDATE PATTERN
// ============================================
interface State {
readonly count: number;
readonly items: readonly string[];
}
function increment(state: State): State {
return { ...state, count: state.count + 1 };
}
function addItem(state: State, item: string): State {
return { ...state, items: [...state.items, item] };
}
const state1: State = { count: 0, items: [] };
const state2 = increment(state1); // state1 unchanged
const state3 = addItem(state2, "apple"); // state2 unchanged
The ten parts progress from shallow to deep, from compile-time to runtime, and end with the immutable update pattern that ties the pieces together.
Quick Reference
Immutability Tools
| Tool | Scope | Type | Runtime |
|---|---|---|---|
readonly | Property | Compile | No |
Readonly<T> | Top level | Compile | No |
ReadonlyArray<T> | Array | Compile | No |
DeepReadonly<T> | Recursive | Compile | No |
as const | Deep + literal | Compile | No |
Object.freeze | Top level | No | Yes |
deepFreeze | Recursive | Partial | Yes |
readonly Placement
| Syntax | Applies To |
|---|---|
readonly x: T | Property |
readonly [K in keyof T] | Mapped type property |
readonly T[] | Array |
ReadonlyArray<T> | Array |
as const | Literal |
as const Effects
| Value | Without as const | With as const |
|---|---|---|
"hello" | string | "hello" |
42 | number | 42 |
[1, 2] | number[] | readonly [1, 2] |
{ x: 1 } | { x: number } | { readonly x: 1 } |
Shallow vs Deep
| Aspect | Shallow | Deep |
|---|---|---|
| Top-level properties | ✅ | ✅ |
| Nested objects | ❌ | ✅ |
| Arrays | ReadonlyArray | ReadonlyArray + recurse |
| Compilation cost | Low | High |
| Type display | Clean | Verbose |
Immutable Update Patterns
| Operation | Pattern |
|---|---|
| Update property | { ...obj, key: newValue } |
| Add to array | [...arr, item] |
| Remove from array | arr.filter(x => x !== item) |
| Update array element | arr.map((x, i) => i === idx ? newValue : x) |
| Nested update | { ...obj, nested: { ...obj.nested, key: v } } |
Best Practices
✅ Do This:
// Use readonly for function parameters that are not mutated
function sum(numbers: readonly number[]): number {
return numbers.reduce((a, b) => a + b, 0);
} // ✅
// Use as const for literal tables and configs
const ROLES = ["admin", "user", "guest"] as const; // ✅
// Use DeepReadonly for deeply nested immutable state
type State = DeepReadonly<AppState>; // ✅
// Use Object.freeze for runtime protection on critical objects
const config = Object.freeze({ host: "localhost" }); // ✅
// Use immutable update patterns
const next = { ...state, count: state.count + 1 }; // ✅
// Combine type and runtime protection
const config = deepFreeze({ ... } as const); // ✅
❌ Don’t Do This:
// Don't assume readonly prevents runtime mutation
const p: Point = { x: 1, y: 2 };
(p as any).x = 3; // compiles, runs, violates the contract // ⚠️
// Don't use Readonly<T> and expect nested protection
type R = Readonly<{ nested: { a: number } }>;
// r.nested.a = 1; // ✅ allowed — shallow only // ⚠️
// Don't use as const when a general type is wanted
const config = { host: "localhost" } as const;
// config.host is "localhost" — cannot accept other strings // ⚠️
// Don't freeze hot-path objects
// Object.freeze has a performance cost // ⚠️
// Don't mutate readonly arrays with methods that exist
// even on ReadonlyArray — sort, reverse mutate in place // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
readonly assumed runtime | Mutation still possible | Use Object.freeze |
Readonly<T> expected deep | Nested mutable | Use DeepReadonly<T> |
as const too narrow | Cannot accept general values | Use explicit type |
Object.freeze shallow | Nested mutable at runtime | Use deep freeze |
Readonly arrays still have sort | Mutates in place | Use [...arr].sort() |
| Deep readonly contagion | Cannot pass to mutable function | Convert or use a boundary |
| Freeze cost in hot path | Performance regression | Freeze only when needed |
| Type-only immutability | Untyped callers mutate | Add runtime freeze |
Real-World Examples
1. Readonly function parameter
function sum(nums: readonly number[]): number { /* ... */ }
2. As const for role list
const ROLES = ["admin", "user"] as const;
type Role = typeof ROLES[number];
3. Deep readonly state
type State = DeepReadonly<{ user: { name: string; roles: string[] } }>;
4. Immutable update
const next = { ...state, user: { ...state.user, name: "Bob" } };
5. Frozen config
const config = Object.freeze({ host: "localhost", port: 8080 });
6. Deep freeze
const frozen = deepFreeze({ server: { host: "localhost" } });
7. Immutable array append
const next = [...items, newItem];
8. Immutable array remove
const next = items.filter(i => i.id !== id);
9. As const for discriminated union
const actions = [{ type: "add" }, { type: "remove" }] as const;
type Action = typeof actions[number];
10. Readonly map
function render(items: readonly Item[]): string { /* ... */ }
Visual: Immutability Levels
┌──────────────────────────────────────────────────────────┐
│ LEVEL 1: readonly PROPERTY │
│ interface P { readonly x: number } │
│ Prevents: p.x = 3 │
│ Does not prevent: p.nested.x = 3 │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 2: Readonly<T> │
│ type R = Readonly<{ a: number; b: string }> │
│ Prevents: r.a = 1 │
│ Does not prevent: r.nested.a = 1 │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 3: DeepReadonly<T> │
│ Recursively applies readonly │
│ Prevents: r.nested.deep.a = 1 │
│ Cost: compilation time, type verbosity │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 4: as const │
│ Deep readonly + literal inference │
│ Prevents: any mutation │
│ Also: narrows types to literals │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 5: Object.freeze / deepFreeze │
│ Runtime enforcement │
│ Prevents: mutation even from untyped code │
│ Cost: performance, shallow unless recursive │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Shallow vs Deep
┌──────────────────────────────────────────────────────────┐
│ Readonly<T> (SHALLOW) │
│ │
│ { │
│ readonly server: { │
│ host: string; ← mutable │
│ port: number; ← mutable │
│ }; │
│ readonly retries: number; │
│ } │
│ │
│ The top level is protected. Nested is not. │
│ │
├──────────────────────────────────────────────────────────┤
│ DeepReadonly<T> │
│ │
│ { │
│ readonly server: { │
│ readonly host: string; ← protected │
│ readonly port: number; ← protected │
│ }; │
│ readonly retries: number; │
│ } │
│ │
│ Every level is protected. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: as const
┌──────────────────────────────────────────────────────────┐
│ WITHOUT as const │
│ │
│ const config = { │
│ host: "localhost", │
│ port: 8080, │
│ protocols: ["http", "https"], │
│ }; │
│ │
│ Type: │
│ { host: string; port: number; protocols: string[] } │
│ │
├──────────────────────────────────────────────────────────┤
│ WITH as const │
│ │
│ const config = { ... } as const; │
│ │
│ Type: │
│ { │
│ readonly host: "localhost"; │
│ readonly port: 8080; │
│ readonly protocols: readonly ["http", "https"]; │
│ } │
│ │
│ Readonly at every level, literal types inferred. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Compile-Time vs Runtime
┌──────────────────────────────────────────────────────────┐
│ COMPILE TIME (readonly) │
│ │
│ const p: Point = { x: 1, y: 2 }; │
│ p.x = 3; │
│ ❌ Error: Cannot assign to 'x' │
│ │
│ Catches typed mistakes. │
│ Erased at runtime. │
│ │
├──────────────────────────────────────────────────────────┤
│ RUNTIME (Object.freeze) │
│ │
│ const p = Object.freeze({ x: 1, y: 2 }); │
│ p.x = 3; │
│ TypeError: Cannot assign to read only property │
│ │
│ Catches all mutations, including untyped. │
│ Costs performance. │
│ │
├──────────────────────────────────────────────────────────┤
│ BOTH │
│ │
│ const p: Readonly<Point> = Object.freeze({ x: 1, y: 2 });│
│ p.x = 3; │
│ ❌ Compile error AND TypeError at runtime │
│ │
│ The complete answer. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Immutable Update Pattern
┌──────────────────────────────────────────────────────────┐
│ MUTABLE │
│ │
│ state.count = 1; │
│ state.items.push("x"); │
│ // same object, changed contents │
│ // old references see the change │
│ │
├──────────────────────────────────────────────────────────┤
│ IMMUTABLE │
│ │
│ const next = { ...state, count: state.count + 1 }; │
│ const withItem = { ...next, items: [...next.items, "x"] };│
│ // new objects at each step │
│ // old references unchanged │
│ │
│ Reference equality is meaningful: │
│ state !== next │
│ next !== withItem │
│ │
└──────────────────────────────────────────────────────────┘
Summary
| Tool | Protects | Cost |
|---|---|---|
readonly property | One property | None |
Readonly<T> | Top level | None |
ReadonlyArray<T> | Array mutations | None |
DeepReadonly<T> | All levels | Compile time |
as const | All levels + literals | Narrow types |
Object.freeze | Runtime top level | Performance |
deepFreeze | Runtime all levels | Performance |
Key takeaways:
- Immutability eliminates a class of bugs by making shared references safe, reference equality meaningful, and reasoning local
readonlyis a compile-time modifier — it prevents assignment in typed code but is erased at runtimeReadonly<T>is shallow — it protects top-level properties but leaves nested objects mutableReadonlyArray<T>makes arrays covariantly sound and removes mutating methods from the typeDeepReadonly<T>recursively protects every level and is written as a recursive conditional mapped type, with real compilation costas constcombines deep readonly with literal type inference — the one-line way to get both precision and protectionObject.freezeis the runtime counterpart and is shallow unless a recursive freeze is used- The type system and the runtime are complementary —
readonlycatches typed mistakes,Object.freezecatches untyped ones, and using both covers the cases that matter - Immutable updates allocate new objects — the pattern is to copy shallowly and replace only the changed parts
- Shallow readonly is usually enough — deep readonly is for the cases where nested protection genuinely matters, and it should be applied where the cost is justified
Remember: TypeScript’s immutability tools form a spectrum from a single readonly property to a recursive deepFreeze that enforces at runtime. The type system catches mistakes at compile time, which is the cheapest place to catch them, and the runtime freeze catches what the type system cannot see. The pattern that works in production is to use the shallow tools by default, reach for as const when literal inference is valuable, and apply deep readonly only where the structure’s depth and the mutability risk justify the cost.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!