QA Interview Hub · Knowledge base · Quizzes

TypeScript

Object types: interface and type, optional, readonly

interface or type

Both ways describe the shape of an object, and for API models the difference is often unnoticeable.

interface Account {
  id: string;
  iban: string;
  balance: number; // in minor units
}

type Transfer = {
  from: string;
  to: string;
  amount: number;
};

The key differences:

  • type can describe anything: a union, a tuple, a primitive, a function, mapped and conditional types. interface describes only the shape of an object or a class.
  • interface supports declaration merging: two declarations with the same name merge. That is how library types are extended, for example adding a field to a global interface.
  • Inheritance: interface B extends A, and for types an intersection type B = A & { ... }. On a field conflict extends gives a clear error at once, while an intersection may silently produce never for the field.
  • Classes can implements both an interface and an object type.
interface ApiError { code: string; message: string }
interface ValidationError extends ApiError {
  fields: string[];
}

type Result = { ok: true; data: Account } | { ok: false; error: ApiError };

The practical rule I follow: interface for data models and class contracts (Page Object, clients), type for unions, utilities and everything more complex. The main thing is consistency in the project, not the "right" choice.

In an interview: For object models there is almost no difference. type is needed for unions, tuples and advanced types, while interface can do declaration merging and gives clearer errors with inheritance. In a project I stick to one convention.

Optional, undefined and null

The ? sign makes a field optional: middleName?: string means the field may be absent entirely, or be undefined. This is not the same as middleName: string | undefined: in the second case the field is required, but may have the value undefined.

interface Customer {
  id: string;
  email: string;
  phone?: string;          // may be absent
  closedAt: string | null; // the field is always there, but may be null
}

For API testing this difference is fundamental. If the contract says closedAt is always present and may be null, and the backend started omitting the field, that is a contract regression. The type must reflect the real contract, not "what is more convenient".

Compiler options that strengthen the checks:

  • strictNullChecks (part of strict): without it null and undefined can be assigned to any type, and half of TS's benefit disappears.
  • exactOptionalPropertyTypes: forbids explicitly assigning undefined to a field with ? if undefined isn't stated in the type.
  • noUncheckedIndexedAccess: access by index arr[0] or record[key] gives T | undefined. Very useful, because rows[0].amount on an empty array is a classic test failure with an unclear error.

Working with optionals, I use optional chaining and nullish coalescing:

const phone = customer.phone ?? 'not specified';
const city = customer.address?.city;

A dangerous trick is the non-null assertion customer.phone!. It tells the compiler "trust me" and checks nothing at runtime. In tests an explicit check with expect(...).toBeDefined() or an assertion function is better.

In an interview: ? means the field may be absent, and | null means the field is there, but empty. For API contracts these are different things, and I reflect them exactly. I turn on strict and noUncheckedIndexedAccess and avoid the ! operator.

readonly and immutability

readonly forbids overwriting a field after the object is created. This is a compiler check; at runtime the object stays ordinary and mutable.

interface TestUser {
  readonly id: string;
  readonly email: string;
  balance: number;
}

const user: TestUser = { id: 'u1', email: 'a@test.io', balance: 0 };
// user.id = 'u2'; // compile error

For arrays there is readonly string[] or ReadonlyArray<string>: push, splice, sort and other methods that mutate the array are unavailable. The utility Readonly<T> makes all top-level fields readonly.

An important nuance is that readonly is shallow. Nested objects remain mutable:

const config: Readonly<{ api: { timeout: number } }> = { api: { timeout: 5000 } };
// config.api = {...}; // error
config.api.timeout = 1; // compiles!

For deep immutability you use as const on literals (it makes everything readonly at all levels) or write your own DeepReadonly type. A real runtime freeze is given by Object.freeze, but it is also shallow.

Where this is useful in automation:

  • Shared test data and configs. If one test mutates a shared object, another test fails depending on run order, a classic flaky. readonly catches such a mutation at compile time.
  • Constants of endpoints, roles, limits.
  • Parameters of helper functions: function sum(amounts: readonly number[]) guarantees that the helper won't sort the caller's array in place.
In an interview: readonly is protection at the compiler level, it is shallow and blocks nothing at runtime. I use it for shared test data and configs, so a test doesn't accidentally change an object that other tests depend on.