Optional and Readonly Properties

7 min read

Real test data is rarely uniform. Some fields are present only when a test fails. Some fields are set once at startup and must never change. TypeScript has two property modifiers — ? (optional) and readonly — that capture exactly these patterns. Used together, they describe the realities of test configuration, fixtures, and API responses far better than a flat list of required fields ever could.

Optional properties — fields that may not be there

A ? after a property name marks it as optional. The field can be omitted from the value, and reading it returns undefined.

interface TestCase {
  id: number;
  title: string;
  description?: string;     // optional — might not be provided
  expectedResult?: string;  // optional
  priority: "high" | "medium" | "low";
}
 
const minimal: TestCase = {
  id: 1,
  title: "Login test",
  priority: "high",
};
// ✅ Valid — description and expectedResult omitted
 
const fuller: TestCase = {
  id: 2,
  title: "Logout test",
  description: "User logs out from any page",
  expectedResult: "Redirected to /login",
  priority: "medium",
};
// ✅ Also valid

The type of description?: string is string | undefined. That second half — undefined — is the part you have to handle when reading the value:

function printDescription(t: TestCase): void {
  console.log(t.description.length);
  // ❌ 't.description' is possibly 'undefined'.
 
  if (t.description) {
    console.log(t.description.length);   // ✅ narrowed to string
  }
}

Strict mode forces you to check. That's the entire point of ? — it makes "this might be missing" visible in the type, so you can't accidentally call .length on a value that might be undefined.

Readonly properties — set once, never changed

Prefix a property with readonly to forbid reassignment after the object is created.

interface TestConfig {
  readonly baseUrl: string;
  readonly timeout: number;
  retryCount: number;     // mutable — not readonly
}
 
const config: TestConfig = {
  baseUrl: "https://staging.app.com",
  timeout: 5000,
  retryCount: 3,
};
 
config.retryCount = 5;       // ✅ allowed
config.baseUrl = "https://prod.app.com";
// ❌ Cannot assign to 'baseUrl' because it is a read-only property.

The compiler enforces this at compile time. At runtime the object is still a regular JavaScript object — Object.freeze is what you'd use for actual runtime immutability. readonly is your team's contract: "by writing this, I'm telling everyone this value is set once."

Why readonly belongs on test config

Three classic places readonly prevents real bugs:

  • Base URLs and environment names. A helper that mutates config.baseUrl to point at production from inside a test creates the worst kind of test pollution — silent and intermittent.
  • API tokens and credentials. Once injected at startup, they should never be reassigned. readonly makes accidental reassignment a compile error.
  • Browser and framework identifiers. A test labelled as running on "chromium" should stay labelled — even if a helper deep in your stack tries to relabel it.

A realistic split: readonly for what's set at startup, mutable for what evolves per test.

interface TestRunConfig {
  readonly env: "dev" | "staging" | "production";
  readonly baseUrl: string;
  readonly browser: "chromium" | "firefox" | "webkit";
  retryCount: number;        // tests may opt into more retries
  timeoutMs: number;         // slow tests may bump the timeout
}

Readonly arrays — protecting collections too

Arrays of test data — supported browsers, valid status codes — usually shouldn't be mutated either. Mark the array readonly and TypeScript blocks push, pop, splice, and index assignment.

interface BrowserConfig {
  readonly supported: readonly string[];
  defaultBrowser: string;
}
 
const cfg: BrowserConfig = {
  supported: ["chromium", "firefox", "webkit"],
  defaultBrowser: "chromium",
};
 
cfg.supported.push("edge");
// ❌ Property 'push' does not exist on type 'readonly string[]'.

You met readonly arrays in chapter 2 on their own. Inside an interface, the modifier composes the same way — block mutation of the array AND block reassignment of the field, by combining readonly with readonly string[].

Readonly<T> — making everything readonly

If every field of an interface should be readonly, the built-in Readonly<T> utility type does it for you in one go:

interface TestConfig {
  baseUrl: string;
  timeout: number;
  retryCount: number;
}
 
const config: Readonly<TestConfig> = {
  baseUrl: "https://staging.app.com",
  timeout: 5000,
  retryCount: 3,
};
 
config.retryCount = 5;
// ❌ Cannot assign to 'retryCount' because it is a read-only property.

Readonly<T> is a mapped type — it walks every field of T and adds the readonly modifier. You'll meet utility types and mapped types properly in chapter 5; for now, treat Readonly<T> as the shortcut for "freeze everything."

Combining ? and readonly

Both modifiers can apply to the same field:

interface TestRun {
  readonly id: string;            // required, never changes
  readonly startedAt: string;     // required, never changes
  readonly finishedAt?: string;   // optional, never changes once set
  notes?: string;                 // optional, freely editable
}

readonly finishedAt?: string reads as "may be omitted; if present, can't be reassigned." That's exactly the model for an "in-progress vs finished" test run.

At a glance

Property modifiers — required vs optional, mutable vs readonly

Plain property — id: number

  • Must be present in every value

  • Can be reassigned freely after creation

  • Right for: fields that exist on every record AND change over time

  • Default — use unless you have a reason not to

Optional — error?: string

  • May be omitted from the value

  • Type is widened to string | undefined

  • Forces callers to narrow before reading

  • Right for: error messages, missing middle names, conditional fields

Readonly — readonly baseUrl

  • Required to be present

  • Cannot be reassigned after the object is created

  • Compile-time guarantee, not runtime freeze

  • Right for: base URLs, tokens, environment ids

A test config that uses both

interface TestRunConfig {
  readonly env: "dev" | "staging" | "production";
  readonly baseUrl: string;
  readonly apiKey: string;
  retryCount: number;
  timeoutMs: number;
  notes?: string;
}
 
const config: TestRunConfig = {
  env: "staging",
  baseUrl: "https://staging.app.com",
  apiKey: "test-key-1234",
  retryCount: 3,
  timeoutMs: 10_000,
};
 
config.retryCount = 5;       // ✅ tests may bump retries
config.notes = "Re-run for flake investigation";  // ✅ optional, mutable
config.env = "production";
// ❌ Cannot assign to 'env' because it is a read-only property.

The interface tells the whole story: what's set at startup, what evolves, what may simply be absent. A new teammate reading it has every rule they need.

⚠️ Common mistakes

  • Forgetting that optional widens to | undefined. A common stumble: t.description.length works in JavaScript and looks fine — until the compiler points out description might be undefined. The fix isn't as string to silence the error; it's an if (t.description) narrowing or optional chaining (t.description?.length).
  • Treating readonly as a runtime guarantee. It's a compile-time check. A (config as any).baseUrl = "..." will mutate the value at runtime. If you need actual immutability against malicious or untyped code, use Object.freeze. For your team's own code, readonly is enough.
  • Marking everything optional "to be safe." Beginners reach for ? whenever they're not sure if a field will be there. The result is an interface where every field is string | undefined and every consumer has to narrow before reading. Mark a field optional only when it's legitimately sometimes absent — not as a way to avoid making decisions.

🎯 Practice task

Type a real test run config. 20-30 minutes.

  1. In your ts-for-qa/src folder, create config-strict.ts.
  2. Define interface TestRunConfig with:
    • readonly env: "dev" | "staging" | "production"
    • readonly baseUrl: string
    • readonly apiKey: string
    • retryCount: number
    • timeoutMs: number
    • notes?: string
  3. Create a config: TestRunConfig value (omit notes).
  4. Write a function bumpRetries(c: TestRunConfig, by: number): void that mutates c.retryCount. Run it and confirm the change.
  5. Try every break. After each, read the error and revert:
    • config.env = "production";
    • config.baseUrl = "https://prod.app.com";
    • Read config.notes.length without narrowing.
  6. Use Readonly<T>. Declare const frozen: Readonly<TestRunConfig> = { ... }. Try to mutate frozen.retryCount — confirm the compile error.
  7. Stretch: add a readonly tags?: readonly string[] field. Confirm you can omit it entirely; when present, you can't push or reassign. This is the typed equivalent of a frozen test fixture.

The next lesson scales up by extending interfaces — building bigger types from smaller ones, the same way you compose data models in real test suites.

// tip to track lessons you complete and pick up where you left off across devices.