QA Interview Hub · База знань · Тести

TypeScript

Обʼєктні типи: interface і type, optional, readonly

interface чи type

Обидва способи описують форму об'єкта, і для моделей API різниця часто непомітна.

interface Account {
  id: string;
  iban: string;
  balance: number; // у копійках
}

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

Ключові відмінності:

  • type може описати будь-що: union, кортеж, примітив, функцію, mapped і conditional типи. interface описує тільки форму об'єкта або класу.
  • interface підтримує declaration merging: два оголошення з однаковим ім'ям зливаються. Саме так розширюють типи бібліотек, наприклад додають поле в глобальний інтерфейс.
  • Наслідування: interface B extends A, у типів - перетин type B = A & { ... }. При конфлікті полів extends дає зрозумілу помилку одразу, а перетин може мовчки дати never для поля.
  • Класи можуть implements і інтерфейс, і об'єктний тип.
interface ApiError { code: string; message: string }
interface ValidationError extends ApiError {
  fields: string[];
}

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

Практичне правило, якого я дотримуюсь: interface для моделей даних і контрактів класів (Page Object, клієнти), type для union, утиліт і всього складнішого. Головне - консистентність у проєкті, а не "правильний" вибір.

На співбесіді: Для об'єктних моделей різниці майже немає. type потрібен для union, кортежів і просунутих типів, а interface вміє declaration merging і дає зрозуміліші помилки при наслідуванні. У проєкті я тримаюся однієї конвенції.

Optional, undefined і null

Знак ? робить поле необов'язковим: middleName?: string означає, що поля може не бути взагалі, або воно undefined. Це не те саме, що middleName: string | undefined: у другому випадку поле обов'язкове, але може мати значення undefined.

interface Customer {
  id: string;
  email: string;
  phone?: string;          // може бути відсутнім
  closedAt: string | null; // поле є завжди, але може бути null
}

Для тестування API ця різниця принципова. Якщо контракт каже, що closedAt завжди присутнє і може бути null, а бекенд почав пропускати поле, - це регресія контракту. Тип має відображати реальний контракт, а не "що зручніше".

Опції компілятора, які посилюють перевірки:

  • strictNullChecks (входить у strict) - без нього null і undefined можна присвоїти будь-якому типу, і половина користі TS зникає.
  • exactOptionalPropertyTypes - забороняє явно присвоїти undefined полю з ?, якщо undefined не вказаний у типі.
  • noUncheckedIndexedAccess - доступ за індексом arr[0] або record[key] дає T | undefined. Дуже корисно, бо rows[0].amount на порожньому масиві - класичне падіння тесту з незрозумілою помилкою.

Працюючи з optional, я використовую optional chaining і nullish coalescing:

const phone = customer.phone ?? 'не вказано';
const city = customer.address?.city;

Небезпечний прийом - non-null assertion customer.phone!. Він каже компілятору "повір мені" і нічого не перевіряє в рантаймі. У тестах краще явна перевірка через expect(...).toBeDefined() або assertion-функцію.

На співбесіді: ? означає, що поля може не бути, а | null - що поле є, але порожнє. Для API-контрактів це різні речі, і я відображаю їх точно. Я вмикаю strict і noUncheckedIndexedAccess і уникаю оператора !.

readonly і незмінність

readonly забороняє перезапис поля після створення об'єкта. Це перевірка компілятора, у рантаймі об'єкт залишається звичайним і змінюваним.

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

const user: TestUser = { id: 'u1', email: 'a@test.io', balance: 0 };
// user.id = 'u2'; // помилка компіляції

Для масивів є readonly string[] або ReadonlyArray<string>: недоступні push, splice, sort та інші методи, що мутують масив. Утиліта Readonly<T> робить readonly всі поля верхнього рівня.

Важливий нюанс - readonly поверхневий. Вкладені об'єкти залишаються змінюваними:

const config: Readonly<{ api: { timeout: number } }> = { api: { timeout: 5000 } };
// config.api = {...}; // помилка
config.api.timeout = 1; // компілюється!

Для глибокої незмінності використовують as const на літералах (він робить readonly на всіх рівнях) або пишуть власний тип DeepReadonly. Справжню рантайм-заморозку дає Object.freeze, але він теж поверхневий.

Де це корисно в автоматизації:

  • Спільні тестові дані і конфіги. Якщо один тест мутує спільний об'єкт, інший тест падає залежно від порядку запуску - класичний flaky. readonly ловить таку мутацію на етапі компіляції.
  • Константи ендпоінтів, ролей, лімітів.
  • Параметри функцій-хелперів: function sum(amounts: readonly number[]) гарантує, що хелпер не відсортує масив викликача на місці.
На співбесіді: readonly - це захист на рівні компілятора, він поверхневий і в рантаймі нічого не блокує. Я використовую його для спільних тестових даних і конфігів, щоб тест випадково не змінив об'єкт, від якого залежать інші тести.