C.W.K.
Stream
Lesson 06 of 06 · published

`readonly`: Writing Once Is a Contract

~9 min · types-interfaces, readonly, immutability, compile-time-contract

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"Compile-time immutability. The runtime doesn't know, the compiler refuses to let you forget."

What readonly does

readonly is a property modifier that prevents writes after construction. interface Box { readonly width: number } means width is read-only — assigning to it after the object exists is a compile error. The property can be read normally; reassignment fails.

This is a compile-time contract. At runtime, the property is a regular property, fully writable. The type system enforces the rule; the JavaScript runtime doesn't know it exists. This is consistent with the two-layer mental model from the Foundations track: types are erased.

Where readonly goes

  • Object property: { readonly id: number; name: string }id is locked, name is mutable.
  • Interface property: interface User { readonly id: number; name: string } — same rule, named declaration.
  • Class field: class Box { readonly width: number; constructor(w: number) { this.width = w } } — the constructor can write, nothing else can.
  • Array type: readonly number[] or ReadonlyArray<number> — push/pop/sort/etc are removed from the type.
  • Tuple type: readonly [number, string] — same idea, position-aware.

Why compile-time-only is enough

The pragmatic answer: most mutation bugs happen in code you control. If your team has agreed not to write to readonly properties, the compiler can enforce that agreement. The runtime would only need to enforce it against malicious code or genuinely external mutation — and those cases are rare in front-end app code.

The deeper answer: making readonly runtime-enforced would require either freezing objects (slow, deep) or wrapping in proxies (slow, intrusive). The TypeScript designers chose the trade: zero runtime cost, real compile-time safety. If you need true runtime immutability, reach for Object.freeze() or a library like Immer.

Pair readonly with as const for the strongest compile-time guarantees on literals. const config = { port: 5173 } as const makes every property readonly and narrows every value to its literal type. The combination is what teams reach for when they want "can't change this, the compiler should refuse."

readonly is shallow by default

readonly { nested: { name: string } } means the outer reference is readonly, but nested.name is still writable. The compiler does not recurse. If you want deep readonly, you can build it with mapped types (covered in the Type Manipulation track), or use the standard Readonly<T> utility — which is also shallow. Deep immutability is a recursive operation you opt into.

Pippa's confession

cwkPippa's frontend uses readonly heavily on configuration shapes and on props that get passed to deeply nested React components. The compiler refuses to let a child mutate the parent's data. The runtime still sees plain JavaScript — but in practice, the contract has caught enough "oops I mutated a prop" mistakes that the rule earns its keep on every refactor.

Code

readonly — property, array, shallow·typescript
// readonly on properties.
interface User {
  readonly id: number;
  name: string;
}

const u: User = { id: 1, name: 'Pippa' };
u.name = 'Pippa2';                  // ✅ name is mutable
u.id = 999;                         // ❌ Cannot assign to 'id' because it is a read-only property

// readonly arrays — mutation methods are removed.
const frozen: readonly number[] = [1, 2, 3];
frozen.push(4);                     // ❌ Property 'push' does not exist on type 'readonly number[]'
frozen[0] = 99;                     // ❌ Index signature is readonly

// readonly is shallow:
interface Wrapped {
  readonly inner: { name: string };
}
const w: Wrapped = { inner: { name: 'Pippa' } };
w.inner = { name: 'Other' };        // ❌ outer is readonly
w.inner.name = 'Mutated';           // ✅ inner is NOT readonly — only the wrapper is
as const and Readonly<T> — and the deep-readonly DIY·typescript
// `as const` and Readonly<T> — two utilities.

// `as const` — every property readonly, every value narrowed to literal.
const route = {
  path: '/api/chat',
  method: 'POST',
} as const;
// route is { readonly path: '/api/chat'; readonly method: 'POST' }
// Both readonly AND narrowed to literal types.

route.path = '/api/foo';            // ❌ readonly

// Readonly<T> — utility-type version, easier to opt-into existing types.
interface MutUser { id: number; name: string }
type FrozenUser = Readonly<MutUser>;
// FrozenUser is { readonly id: number; readonly name: string }

// Both are shallow. For deep, you'd build:
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

External links

Exercise

Define interface AuditLogEntry { readonly id: number; readonly timestamp: Date; message: string } — id and timestamp are written once at creation, message can be updated. Write a function redact(e: AuditLogEntry): AuditLogEntry that returns a copy with message: '[redacted]'. Confirm you can't mutate id or timestamp inside, even by accident. Now try to deep-mutate: e.timestamp.setHours(0) — does it compile? Why?
Hint
readonly is shallow. The Date object's internal mutations aren't blocked by readonly Date — the reference is read-only, but the methods on Date are still callable. To prevent that, you'd need Readonly<Date> (which removes mutating methods) or DIY deep-readonly.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.