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
readonlywhenever 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
readonlyis shallow: for nested objects or arrays you want fully locked down, mark inner propertiesreadonlytoo, or use theReadonly<T>/ReadonlyArray<T>utility types. - Don’t rely on
readonlyfor 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
Bookwith requiredtitle: stringandauthor: string, an optionalisbn?: string, and areadonly publishedYear: number. Write a function that logs all four fields, substituting"Unknown ISBN"whenisbnis missing. - Given an interface
Account { readonly id: string; balance: number; }, write code that creates an account, updates itsbalance, and then attempts to reassign itsid— confirm (by reasoning about the error, or by pasting into an editor) which line the compiler rejects and why. - Rewrite a
Settingsinterface that has a nestedreadonly theme: { color: string }so that the innercolorfield is also protected from reassignment, and explain in a comment why the original version wasn’t fully immutable.
Summary
prop?: Tmakes a property optional — it may be omitted from the object, and its type becomesT | undefinedwhen accessed.readonly prop: Tprevents 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
?orreadonly, and neither offers runtime enforcement on its own. readonlyis 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
readonlyto model identity or fixed configuration values.
