RizTech Academy logo
RizTech Academy
Objects, Interfaces and Type AliasesLesson 4 of 520 min

Optional and readonly properties

Real objects are not all-or-nothing. A customer might have no email; an order's delivery date is set only after it ships; an id should never change once created. TypeScript expresses these with two small modifiers on properties — optional (?) and readonly — and using them precisely is what makes a type an honest description of your data.

Optional properties — ?

A ? after a property name marks it optional — the object may or may not have it:

interface Member {
  name: string;
  email?: string;       // optional — may be absent
  phone?: string;       // optional
}

const a: Member = { name: "Kavita", email: "kavita@example.com" };   // fine — phone omitted
const b: Member = { name: "Ravi" };                                   // fine — both omitted
const c: Member = { name: "Neha", email: "neha@example.com", phone: "9876500001" };  // fine

email?: string means "this object may have an email of type string, or may not have it at all". All three objects above are valid Members, because the optional properties can be present or absent. A required property (no ?) must always be there.

The crucial part is what the type of an optional property becomes when you read it:

const m: Member = { name: "Kavita" };
m.email.toUpperCase();      // error: 'm.email' is possibly 'undefined'

m.email has type string | undefined — it might be the string, or it might be absent (undefined). TypeScript forces you to handle the absence before using it, exactly like the nullable values from the primitives lesson. This is the whole point: an optional property is honest about "might not be here", and the compiler makes you deal with it. You handle it with the narrowing tools you will learn — a check, ?., or ??:

if (m.email) {
  m.email.toUpperCase();    // fine — inside the check, email is a string
}
const email = m.email ?? "no email";   // a default with the nullish operator
console.log(m.email?.toUpperCase());   // optional chaining — only calls if present

optional?: T is really T | undefined, and treating it as such — always handling the undefined case — is a first-week habit that prevents a whole class of crash.

? versus | undefined — a subtle difference

They are almost the same, with one distinction worth knowing:

interface A { x?: number; }              // x may be ABSENT or a number
interface B { x: number | undefined; }   // x must be PRESENT, but may be the value undefined

const a: A = {};                 // fine — x absent
const b: B = {};                 // error: Property 'x' is missing
const b2: B = { x: undefined };  // fine — x present, value undefined

x?: number allows the property to be missing entirely. x: number | undefined requires the property to be present but permits its value to be undefined. Usually you want ? (the property is genuinely optional). Reach for | undefined on a required property only when you specifically want to force callers to acknowledge the field exists by setting it, even to undefined. Most of the time, ? is what you mean.

readonly properties — set once, never change

readonly before a property means it can be set when the object is created but never reassigned:

interface Member {
  readonly id: string;      // set at creation, immutable thereafter
  name: string;             // mutable
}

const m: Member = { id: "M1", name: "Kavita" };
m.name = "Kavita Joshi";    // fine — name is mutable
m.id = "M2";                // error: Cannot assign to 'id' because it is a read-only property

readonly id says "this identity is fixed". You set it once, and any attempt to change it is a compile error. This models data that genuinely should not change after creation — an id, a creation timestamp, a currency of a transaction — and it prevents an entire category of bug where something is mutated that should have been constant. Reach for readonly on any property that should be set once and never change; it costs one word and documents intent the compiler enforces.

Note the same caveat as readonly arrays: readonly is a compile-time check and is shallow. It stops you reassigning the property, but it does not deeply freeze a nested object, and since types are erased, it does not prevent mutation at runtime through some other reference. It is a design-time guarantee about your code, not a runtime lock. In practice that is exactly what you want, and it catches the mistakes that matter.

Combining them, and modelling honestly

Optional and readonly compose, and together they let a type tell the truth about your data:

interface Order {
  readonly id: string;
  readonly createdAt: Date;
  status: "pending" | "paid" | "shipped" | "delivered";
  deliveredAt?: Date;         // only set once delivered
  note?: string;              // may never be set
}

Read that type as a description: an order has an unchangeable id and creation time, a mutable status, and a delivery date that is absent until it is delivered. That deliveredAt?: Date is doing real work — it says, in the type, "do not assume this exists", so every piece of code that touches it is forced to handle the not-yet-delivered case. This is the recurring theme again: model your data so the type reflects reality, and the compiler will enforce the reality for you. A type where everything is required and mutable is often lying about the data it describes.

Check your work

What ? marks. An optional property — the object may or may not have it.

What the type of an optional property becomes when read. T | undefined — TypeScript forces you to handle the absence.

Three ways to handle an optional property. A check (if (m.email)), nullish coalescing (??), and optional chaining (?.).

x?: T versus x: T | undefined. ? allows the property to be missing entirely; | undefined requires it present but allows the value undefined.

What readonly does. Allows a property to be set at creation but never reassigned — a compile error on any change.

When to use readonly. For any property that should be set once and never change (id, createdAt).

The readonly caveat. It is a compile-time, shallow check — not a deep runtime freeze (types are erased).

The theme both serve. Modelling data honestly so the type reflects reality and the compiler enforces it.

Practice

  1. Add an optional email?: string to an interface. Construct objects with and without it; confirm both are valid.
  2. Read the optional property and confirm its type is string | undefined. Try to use it directly and read the error.
  3. Handle the optional three ways: a check, ??, and ?.. Confirm each compiles.
  4. Compare x?: number and x: number | undefined. Try constructing {} for each and read which errors.
  5. Mark a property readonly, set it at creation, then try to reassign it. Read the error.
  6. Reassign a nested property of a readonly object and note that shallow readonly does not stop it.
  7. Design an Order interface using readonly and ? to reflect which fields are fixed and which are set later. Justify each modifier.

Official documentation

Next: nested and recursive structures.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship