TypeScriptOptional & Readonly Properties

Optional & Readonly Properties

TypeScript gives you two powerful modifiers for object properties: optional (?) and readonly. Together they let you model exactly which fields are required, which can be absent, and which should never change after creation — all checked at compile time, with zero runtime overhead.

Optional Properties with ?

Appending ? to a property name tells TypeScript that the property may or may not be present on an object. Under the hood, TypeScript widens the type of that property to T | undefined.

TS

interface UserProfile {
  id: number;
  name: string;
  bio?: string;       // string | undefined
  avatarUrl?: string; // string | undefined
}

// All three are valid:
const minimal: UserProfile = { id: 1, name: 'Alice' };
const withBio: UserProfile = { id: 2, name: 'Bob', bio: 'Engineer' };
const full: UserProfile = { id: 3, name: 'Carol', bio: 'Designer', avatarUrl: '/img/carol.png' };
      

When you access an optional property TypeScript reminds you that it might be undefined, so you must handle that case before using it as its base type.

TS

function displayBio(user: UserProfile): string {
  // Error: Object is possibly 'undefined'
  // return user.bio.toUpperCase();

  // Optional chaining
  return user.bio?.toUpperCase() ?? 'No bio provided';
}
      
Tip
Think of prop?: T as shorthand for prop: T | undefined. TypeScript treats them almost identically — the main difference is that an optional property can be absent from the object literal, while an explicit undefined value still requires the key to be written.
Optional vs. Explicitly Undefined

This is a subtle but important distinction. An optional property (prop?) means the key may be completely absent from the object. An explicit prop: T | undefined means the key must be present, but its value may be undefined.

TS

interface WithOptional {
  label?: string; // key can be omitted entirely
}

interface WithExplicitUndefined {
  label: string | undefined; // key must exist, value may be undefined
}

// Both accept an explicit undefined value
const a: WithOptional          = { label: undefined };
const b: WithExplicitUndefined = { label: undefined };

// Optional allows the key to be missing entirely
const c: WithOptional = {};

// Explicit undefined requires the key to be present
// const d: WithExplicitUndefined = {}; // Error: Property 'label' is missing
      
Note
With TypeScript's exactOptionalPropertyTypes compiler option enabled, the distinction becomes even stricter: prop?: string no longer accepts prop: undefined — you must write prop?: string | undefined explicitly if you want both behaviours.
The readonly Modifier

Prefixing a property with readonly tells TypeScript that the property can only be assigned during object creation — any later assignment is a compile-time error. This is not enforced at runtime; it is purely a static safety net.

TS

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

const origin: Point = { x: 0, y: 0 };

// Cannot assign to 'x' because it is a read-only property
// origin.x = 5;

// Create a new object instead
const moved: Point = { x: origin.x + 5, y: origin.y };
      

readonly works on class properties too, where it pairs naturally with constructor initialisation.

TS

class Circle {
  readonly radius: number;

  constructor(radius: number) {
    this.radius = radius; // assignment in constructor is allowed
  }

  scale(factor: number): Circle {
    return new Circle(this.radius * factor); // return new instance
  }
}

const c = new Circle(5);
// c.radius = 10; // Error at compile time
      
The Readonly<T> Utility Type

Instead of manually marking every property readonly, TypeScript ships a built-in utility type Readonly<T> that wraps an existing type and makes all its top-level properties readonly.

TS

interface Config {
  apiUrl: string;
  timeout: number;
  retries: number;
}

// Every property is now readonly
type FrozenConfig = Readonly<Config>;

const config: FrozenConfig = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3,
};

// config.timeout = 10000; // Error: cannot assign to readonly property
      

Readonly<T> is extremely useful for function parameters that should not be mutated by the callee.

TS

function printConfig(cfg: Readonly<Config>): void {
  console.log(`API: ${cfg.apiUrl}`);
  console.log(`Timeout: ${cfg.timeout}ms`);
  // cfg.retries = 0; // TypeScript prevents accidental mutation
}
      
Tip
Accepting Readonly<T> in function signatures is a great way to document intent — callers know their object won't be modified, and TypeScript enforces it.
ReadonlyArray — Don't Mutate Arrays

A common mistake is assuming readonly on an array property prevents mutation of the array's contents. It only prevents reassigning the property itself. To lock down the contents, use ReadonlyArray<T> (or the shorthand readonly T[]).

TS

interface Team {
  readonly name: string;
  readonly members: ReadonlyArray<string>; // or: readonly string[]
}

const team: Team = {
  name: 'Frontend',
  members: ['Alice', 'Bob', 'Carol'],
};

// team.name = 'Backend';          // readonly property
// team.members = ['Dave'];        // readonly property
// team.members.push('Dave');      // push does not exist on ReadonlyArray
// team.members[0] = 'Eve';        // index signature is readonly

// Create a new array instead
const updated: Team = {
  ...team,
  members: [...team.members, 'Dave'],
};
      
Warning
Without ReadonlyArray, a readonly members: string[] property still allows team.members.push('Dave') — the array reference is frozen but the array contents are not.
The DeepReadonly Pattern

Readonly<T> is shallow — it only freezes the top-level properties. Nested objects can still be mutated. For truly immutable nested structures, you need a recursive DeepReadonly type.

TS

type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface AppState {
  user: {
    profile: {
      name: string;
      age: number;
    };
    settings: {
      theme: string;
    };
  };
}

type FrozenAppState = DeepReadonly<AppState>;

declare const state: FrozenAppState;

// state.user.profile.name = 'Bob';    // deeply readonly
// state.user.settings.theme = 'dark'; // deeply readonly
      
Note
Libraries like immer and ts-essentials ship production-ready DeepReadonly implementations with edge-case handling. Prefer those over a hand-rolled version in larger projects.
Partial<T> — All Properties Optional

Partial<T> is the counterpart to Readonly<T>. It takes a type and makes every property optional. This is perfect for update payloads, patch endpoints, and form state.

TS

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

// Patch payload — only send what changed
type ProductPatch = Partial<Product>;

function updateProduct(id: number, patch: ProductPatch): void {
  // Only the provided fields get written to the database
  console.log(`Updating product ${id} with:`, patch);
}

// Valid — only updating price
updateProduct(42, { price: 19.99 });

// Valid — updating name and category
updateProduct(42, { name: 'Widget Pro', category: 'Electronics' });
      
Required<T> — All Properties Required

Required<T> is the inverse of Partial<T>. It strips the ? from every property, making them all mandatory. Useful for internal processing after validation has confirmed all fields exist.

TS

interface FormValues {
  username?: string;
  email?: string;
  password?: string;
}

// After validation, every field is guaranteed to be present
type ValidatedFormValues = Required<FormValues>;

function createAccount(values: ValidatedFormValues): void {
  // TypeScript knows all properties are defined — no ?. needed
  console.log(`Creating account for ${values.username}`);
}

function validateAndCreate(raw: FormValues): void {
  if (!raw.username || !raw.email || !raw.password) {
    throw new Error('All fields are required');
  }
  // Type assertion after manual validation
  createAccount(raw as ValidatedFormValues);
}
      
Combining Optional and Readonly

Optional and readonly are independent modifiers and can be freely combined on the same property. This is common in real-world types that represent external data (API responses, config files).

TS

interface ApiResponse<T> {
  readonly status: number;
  readonly data: T;
  readonly error?: string;   // present only on failure
  readonly meta?: {          // optional metadata block
    readonly page: number;
    readonly total: number;
  };
}

// Success response
const success: ApiResponse<string[]> = {
  status: 200,
  data: ['item1', 'item2'],
};

// Error response
const failure: ApiResponse<never> = {
  status: 404,
  data: undefined as never,
  error: 'Resource not found',
};

// success.status = 201; // readonly
// success.error = 'oops'; // readonly
      
Practical Example: Config Objects

A real-world pattern that combines all these concepts is a layered configuration system: a loose Partial type for user-supplied overrides, and a strict Readonly type for the resolved config that the rest of the app consumes.

TS

// Full config shape — all fields required
interface ServerConfig {
  host: string;
  port: number;
  ssl: boolean;
  maxConnections: number;
  logLevel: 'debug' | 'info' | 'warn' | 'error';
}

// Defaults — every field present
const DEFAULTS: Readonly<ServerConfig> = {
  host: 'localhost',
  port: 3000,
  ssl: false,
  maxConnections: 100,
  logLevel: 'info',
};

// Public API accepts partial overrides
function createConfig(overrides: Partial<ServerConfig> = {}): Readonly<ServerConfig> {
  return Object.freeze({ ...DEFAULTS, ...overrides });
}

// Consumers get a fully-typed, immutable config
const config = createConfig({ port: 8080, ssl: true });

console.log(config.port);     // 8080
console.log(config.logLevel); // 'info'
// config.port = 9000; // Object is frozen at runtime AND readonly at compile time
      

8080
'info'
      
Practical Example: API Response Modelling

API responses are a natural fit for readonly types — you receive data from the server and should treat it as immutable, transforming it into new objects rather than patching in place.

TS

// Raw shape returned by the REST API
interface RawUser {
  readonly id: string;
  readonly created_at: string;   // ISO string from JSON
  readonly updated_at: string;
  readonly display_name: string;
  readonly email: string;
  readonly avatar_url?: string;  // not all users have avatars
  readonly role?: 'admin' | 'member' | 'viewer';
}

// Enriched domain model used inside the app
interface User {
  readonly id: string;
  readonly createdAt: Date;       // parsed date
  readonly updatedAt: Date;
  readonly displayName: string;
  readonly email: string;
  readonly avatarUrl: string;     // guaranteed — fallback applied
  readonly role: 'admin' | 'member' | 'viewer'; // guaranteed — default applied
}

function mapUser(raw: Readonly<RawUser>): Readonly<User> {
  return Object.freeze({
    id:          raw.id,
    createdAt:   new Date(raw.created_at),
    updatedAt:   new Date(raw.updated_at),
    displayName: raw.display_name,
    email:       raw.email,
    avatarUrl:   raw.avatar_url ?? '/avatars/default.png',
    role:        raw.role        ?? 'member',
  });
}

async function fetchUser(id: string): Promise<Readonly<User>> {
  const res  = await fetch(`/api/users/${id}`);
  const raw  = await res.json() as RawUser;
  return mapUser(raw);
}
      
Tip
Mapping raw API data through a function like mapUser is sometimes called the anti-corruption layer pattern. Keeping it in one place means the rest of your codebase works with clean, readonly domain types, not fragile raw JSON shapes.
Utility Type Quick Reference

Utility

Effect

Typical Use-case

Partial<T>

All properties become optional

Update payloads, form state, patch endpoints

Required<T>

All properties become required

After validation, internal processing

Readonly<T>

All top-level properties become readonly

Immutable config, frozen state snapshots

ReadonlyArray<T>

Array contents become readonly

Immutable lists, Redux state slices

DeepReadonly<T>

All nested properties become readonly (custom)

Deep freeze of complex state trees

Common Mistakes
  • Forgetting that readonly is compile-time only — Object.freeze() is needed for runtime safety

  • Using Readonly<T> and expecting nested objects to be frozen — it is shallow

  • Using readonly on an array property and expecting .push() to be blocked — use ReadonlyArray<T> instead

  • Confusing optional (prop?: T) with nullable (prop: T | null) — they are different concepts

  • Forgetting to handle the undefined case when reading optional properties without a fallback

  • Enabling exactOptionalPropertyTypes without updating existing code that assigns undefined to optional props

Best Practices
  1. Prefer readonly for properties that should not change after construction — it documents intent clearly

  2. Accept Readonly<T> in function parameters to signal the function will not mutate its input

  3. Use Partial<T> for update and patch payloads rather than making the whole type optional

  4. Pair Object.freeze() with Readonly<T> when you need runtime immutability in addition to compile-time safety

  5. Use ReadonlyArray instead of a readonly reference to a mutable array

  6. Model optional fields with ? only when absence is semantically different from an undefined value

  7. Consider exactOptionalPropertyTypes in strict new projects for maximum precision

  8. Apply DeepReadonly to Redux or Zustand state slices to prevent accidental mutations in reducers

Success
You now understand optional and readonly properties in depth. These two modifiers — combined with the Partial, Required, and Readonly utility types — let you build precise, self-documenting types that catch entire classes of bugs at compile time without adding a single line of runtime code.