TypeScript Optional and Readonly Properties

Real-world objects rarely have every field filled in, and some fields should never change once an object is created. TypeScript lets you express both ideas directly in an interface: the ? modifier marks a property as optional, and the readonly modifier marks a property as immutable after creation. Together they let your types describe not just what shape an object has, but how that shape is allowed to be used.

Overview: How Optional and Readonly Work

An interface normally requires every listed property to be present. Adding a question mark after the property name, like bio?: string, tells the compiler that the property may be omitted entirely. Internally, TypeScript treats an optional property’s type as a union with undefined — so bio?: string is equivalent to saying the value is string | undefined when it exists, plus permission to leave the key out of the object literal altogether. This is different from writing bio: string | undefined without the question mark, which still requires the key to be present (just possibly holding undefined).

The readonly modifier works at a different level: it doesn’t change what values are allowed, it changes what the compiler lets you do with the property after the object exists. Once a property is marked readonly, any attempt to assign a new value to it outside of the initial object literal is a compile-time error. This is purely a compile-time construct — TypeScript enforces it during type checking, but since types are erased when the code compiles to JavaScript, there is no runtime protection. A readonly property can still be reassigned via Object.assign, type casting to any, or plain JavaScript code that ignores the types, so it’s a discipline tool for TypeScript-aware code, not a runtime freeze like Object.freeze().

Both modifiers are structural: they describe the shape of an object, not a class or a specific value. Any object literal, class instance, or function return value that satisfies the shape (has the required properties, doesn’t reassign the readonly ones) is assignable to that interface, regardless of how it was constructed.

Syntax

Both modifiers are written directly before the property name inside an interface (or type alias) body:

interface Name {
  requiredProp: Type;
  optionalProp?: Type;
  readonly readonlyProp: Type;
  readonly optionalReadonlyProp?: Type;
}
Syntax Meaning
prop: T Must be present; type is exactly T
prop?: T May be omitted; when present, type is T; effectively T | undefined
readonly prop: T Must be present; cannot be reassigned after the object is created
readonly prop?: T May be omitted; if present, cannot be reassigned afterward

The order of readonly and ? doesn’t matter for the compiler, but the convention above (readonly first, then the name, then ?) matches how TypeScript itself prints these types.

Examples

Example 1: Optional properties for flexible object shapes

interface UserProfile {
  name: string;
  email: string;
  bio?: string;
}

function printProfile(profile: UserProfile): void {
  console.log(`Name: ${profile.name}`);
  console.log(`Email: ${profile.email}`);
  console.log(`Bio: ${profile.bio ?? "No bio provided"}`);
}

printProfile({ name: "Ava", email: "ava@example.com" });
printProfile({ name: "Ben", email: "ben@example.com", bio: "Loves TypeScript" });

Output:

Name: Ava
Email: ava@example.com
Bio: No bio provided
Name: Ben
Email: ben@example.com
Bio: Loves TypeScript

Because bio is optional, the first call is allowed to omit it entirely. Inside the function, profile.bio has the type string | undefined, so the nullish coalescing operator ?? supplies a fallback when it’s missing.

Example 2: Readonly properties to prevent reassignment

interface Point {
  readonly x: number;
  readonly y: number;
}

function logDistanceFromOrigin(point: Point): void {
  const distance = Math.sqrt(point.x ** 2 + point.y ** 2);
  console.log(`Distance: ${distance.toFixed(2)}`);
}

const origin: Point = { x: 3, y: 4 };
logDistanceFromOrigin(origin);

// origin.x = 10; // Compile error: Cannot assign to 'x' because it is a read-only property.

Output:

Distance: 5.00

x and y can only be set once, in the object literal that creates origin. Any later line that tries origin.x = 10 is rejected by the compiler, which is exactly what you want for value-like data such as coordinates.

Example 3: Combining optional and readonly in a realistic config type

interface ServerConfig {
  readonly host: string;
  readonly port: number;
  timeout?: number;
  retries?: number;
}

function startServer(config: ServerConfig): void {
  const timeout = config.timeout ?? 30000;
  const retries = config.retries ?? 3;
  console.log(`Starting server at ${config.host}:${config.port}`);
  console.log(`Timeout: ${timeout}ms, Retries: ${retries}`);
}

const devConfig: ServerConfig = { host: "localhost", port: 3000 };
const prodConfig: ServerConfig = {
  host: "api.example.com",
  port: 443,
  timeout: 5000,
  retries: 5,
};

startServer(devConfig);
startServer(prodConfig);

Output:

Starting server at localhost:3000
Timeout: 30000ms, Retries: 3
Starting server at api.example.com:443
Timeout: 5000ms, Retries: 5

This is a very common real-world pattern: identity fields like host and port are required and locked in with readonly, while tunable settings like timeout and retries are optional and get sensible defaults with ??.

Under the Hood: What the Compiler Checks

When you write bio?: string, the type checker records the property’s type internally as string | undefined and additionally marks the property as “optional” in the object type’s metadata (this is why omitting the key entirely is legal, whereas an explicit non-optional string | undefined property would still require the key to exist). You can see this union nature directly:

interface Item {
  label: string;
  count?: number;
}

function describe(item: Item): string {
  if (item.count === undefined) {
    return `${item.label}: no count set`;
  }
  return `${item.label}: ${item.count}`;
}

console.log(describe({ label: "Widget" }));
console.log(describe({ label: "Gadget", count: 5 }));

Output:

Widget: no count set
Gadget: 5

Because item.count is typed as number | undefined, the compiler forces you to narrow it (with the === undefined check) before treating it as a plain number — this is what makes optional properties safe rather than just convenient.

For readonly, the checker treats assignment expressions as a special case: any time the left-hand side of an = resolves to a property flagged readonly, it reports an error, unless the assignment is part of the original object literal or inside a constructor initializing a class’s own readonly field. Crucially, both ? and readonly are erased completely when TypeScript compiles to JavaScript — the emitted JS has no question marks, no readonly keyword, and no runtime check preventing reassignment. The safety is entirely at compile time.

Common Mistakes

Mistake 1: Forgetting that an optional property can be undefined

interface UserProfile {
  name: string;
  bio?: string;
}

const u: UserProfile = { name: "Ava" };
console.log(u.bio.toUpperCase()); // Error: Object is possibly 'undefined'.

Under --strict, tsc reports “Object is possibly ‘undefined'” because u.bio has type string | undefined, and undefined has no toUpperCase method. The fix is to check or default the value before using it:

interface UserProfile {
  name: string;
  bio?: string;
}

const u: UserProfile = { name: "Ava" };
console.log((u.bio ?? "").toUpperCase());

Mistake 2: Assuming readonly makes an object deeply immutable

interface Wrapper {
  readonly data: { value: number };
}

const w: Wrapper = { data: { value: 1 } };
w.data.value = 42; // Compiles fine! readonly is shallow, not deep.
console.log(w.data.value);

Output:

42

readonly only stops you from reassigning w.data itself (e.g. w.data = otherObject) — it says nothing about the properties inside the object that data points to. To lock the nested value too, mark the inner property readonly as well:

interface DeepWrapper {
  readonly data: {
    readonly value: number;
  };
}

const dw: DeepWrapper = { data: { value: 1 } };
// dw.data.value = 42; // Error: Cannot assign to 'value' because it is a read-only property.
console.log(dw.data.value);

Output:

1

Best Practices

  • Use ? only for properties that are genuinely allowed to be absent — if a value should always exist but might be empty, prefer a required property with a sentinel like "" or an explicit union instead of making it optional.
  • Always narrow or default optional properties (if (x !== undefined), ??, or optional chaining ?.) before using them — never assume they’re present just because your test data happened to include them.
  • Mark properties readonly whenever they represent an identity or a value that should be set once at creation and never changed, such as IDs, timestamps, or configuration that’s fixed for an object’s lifetime.
  • Remember readonly is shallow: for nested objects or arrays you want fully locked down, mark inner properties readonly too, or use the Readonly<T> / ReadonlyArray<T> utility types.
  • Don’t rely on readonly for runtime safety — it disappears after compilation, so validate untrusted data (e.g. from JSON) at your application’s boundaries if true immutability matters at runtime.
  • Combine optional and readonly freely (readonly prop?: T) when a value is both set-once and not always provided, such as an optional, immutable creation timestamp.

Practice Exercises

  • Define an interface Book with required title: string and author: string, an optional isbn?: string, and a readonly publishedYear: number. Write a function that logs all four fields, substituting "Unknown ISBN" when isbn is missing.
  • Given an interface Account { readonly id: string; balance: number; }, write code that creates an account, updates its balance, and then attempts to reassign its id — confirm (by reasoning about the error, or by pasting into an editor) which line the compiler rejects and why.
  • Rewrite a Settings interface that has a nested readonly theme: { color: string } so that the inner color field is also protected from reassignment, and explain in a comment why the original version wasn’t fully immutable.

Summary

  • prop?: T makes a property optional — it may be omitted from the object, and its type becomes T | undefined when accessed.
  • readonly prop: T prevents reassigning the property after the object literal that created it, but only at compile time.
  • Both modifiers are erased at compile time — the emitted JavaScript has no trace of ? or readonly, and neither offers runtime enforcement on its own.
  • readonly is shallow: it protects the property slot, not the contents of an object or array stored in it.
  • Always narrow optional properties before use, and reach for readonly to model identity or fixed configuration values.