Optional and readonly properties

TypeScript · Practical · 10 min

These modifiers document intent and catch accidental mutation, but they behave differently from what many people assume.

`readonly` on a property forbids assigning to it after creation:

interface Order { readonly id: string; total: number }
order.id = "x"; // error

It is a compile-time check only — nothing is frozen at runtime — and it is shallow: readonly items: string[] stops you replacing the array but not calling items.push(). Use readonly string[] for the array itself, or Object.freeze when you need runtime protection. Also, readonly is not enforced through aliasing: a { readonly id: string } value can be assigned to a variable typed { id: string }, which can then change it.

Optional properties (email?: string) may be absent. When you read one, its type is string | undefined, so you must handle the missing case. By default TypeScript also lets you assign undefined explicitly — { email: undefined } — which isn't quite the same as the key being absent ("email" in obj differs). The exactOptionalPropertyTypes option makes the distinction strict if your code cares.

Optional vs | undefined: email?: string means callers may omit the key; email: string | undefined means they must write it, even if the value is undefined. The second is useful when you want every call site to make an explicit choice.

For function parameters, the same ideas apply: readonly arrays as inputs advertise that you won't mutate them, and optional parameters must come after required ones.

Utility types (a later lesson) do these transformations wholesale: Readonly<T> and Partial<T> make every property readonly or optional, and Required<T> removes the ?.

Example

interface Order {
  readonly id: string;
  readonly items: string[];          // the reference is readonly...
  note?: string;
  coupon: string | undefined;        // must be written, may be undefined
}

const order: Order = { id: "A1", items: ["pen"], coupon: undefined };

// @ts-expect-error -- Cannot assign to 'id' because it is a read-only property
order.id = "B2";
order.items.push("ink");             // ...but the array itself is mutable

function noteLength(o: Order): number {
  return o.note?.length ?? 0;        // note: string | undefined
}

const frozen: { readonly tags: readonly string[] } = { tags: ["a"] };
// @ts-expect-error -- Property 'push' does not exist on type 'readonly string[]'
frozen.tags.push("b");

console.log(order.items, noteLength(order), frozen.tags);

Remember

  • readonly is compile-time only and shallow
  • Use readonly T[] to block mutation of the array itself
  • p?: T = key may be missing; p: T | undefined = key required

Watch out for

  • Believing readonly makes nested arrays or objects immutable.

Check yourself

  1. interface S { readonly list: number[] }. Which statement compiles?
  2. How do email?: string and email: string | undefined differ for someone creating the object?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/2/objects.html#property-modifiers