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

TypeScript

Звуження типів і type guards

Базове звуження: typeof, instanceof, in, truthiness

Narrowing - це коли TS після перевірки розуміє, що змінна має вужчий тип. Компілятор аналізує потік керування: if, switch, ранній return, throw.

function formatAmount(value: string | number): string {
  if (typeof value === 'number') {
    return value.toFixed(2); // тут value: number
  }
  return value.trim(); // тут value: string
}

Основні інструменти:

  • typeof - для примітивів: 'string', 'number', 'boolean', 'undefined', 'object', 'function', 'bigint', 'symbol'. Пастка: typeof null === 'object', тому для об'єктів завжди додаю !== null.
  • instanceof - для класів: if (err instanceof Error). У catch змінна має тип unknown (при useUnknownInCatchVariables, входить у strict), тож це звичний патерн.
  • in - перевірка наявності поля: if ('errors' in body).
  • Truthiness - if (value). Обережно: відсіює і 0, і порожній рядок. Для суми переказу 0 - валідне значення, яке так можна загубити.
  • Рівність - if (status === 'FAILED') звужує union літералів.
try {
  await client.createTransfer(payload);
} catch (err) {
  if (err instanceof Error) {
    console.log(err.message);
  } else {
    console.log('Non-error thrown', err);
  }
}

Звуження працює і через ранній вихід: після if (!account) throw new Error('not found') змінна account далі вже не undefined.

На співбесіді: Звуження - це коли після перевірки typeof, instanceof, in чи порівняння TS сам розуміє точніший тип. Я пам'ятаю про пастки: typeof null дає 'object', а перевірка на truthy відсіює нуль, що для грошей критично.

Discriminated unions

Дискримінований union - це об'єднання типів зі спільним полем-літералом (дискримінантом). Перевірка цього поля звужує весь об'єкт. Це найчистіший спосіб описати відповіді, які бувають різної форми.

type ApiResult<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: { code: string; message: string } };

function unwrap<T>(res: ApiResult<T>): T {
  if (res.status === 'error') {
    throw new Error(`${res.error.code}: ${res.error.message}`);
  }
  return res.data; // тут res - гілка success
}

Класичний приклад з фінтеху - платіжні методи або події:

type Payment =
  | { kind: 'card'; pan: string; expiry: string }
  | { kind: 'iban'; iban: string }
  | { kind: 'wallet'; walletId: string };

function describe(p: Payment): string {
  switch (p.kind) {
    case 'card': return `Card *${p.pan.slice(-4)}`;
    case 'iban': return `IBAN ${p.iban}`;
    case 'wallet': return `Wallet ${p.walletId}`;
  }
}

Якщо функція має тип повернення і switch покриває не всі варіанти, при strict компілятор скаже, що функція може повернути undefined. Для явності додаю гілку default з never.

Чому це краще, ніж один тип з купою optional полів: з { pan?: string; iban?: string; walletId?: string } можна створити безглуздий об'єкт без жодного поля або з усіма відразу. Union робить неможливі стани непредставними.

На співбесіді: Discriminated union - це union зі спільним полем на кшталт status чи kind, і перевірка цього поля звужує весь об'єкт. Я так описую відповіді success або error і різні типи платежів, щоб невалідні комбінації полів просто не компілювалися.

Користувацькі type guards і assertion functions

Коли вбудованих перевірок мало, пишуть власний type guard - функцію з типом повернення value is T. Якщо вона повертає true, TS звужує змінну до T.

interface Account { id: string; balance: number; currency: string }

function isAccount(value: unknown): value is Account {
  return (
    typeof value === 'object' && value !== null &&
    typeof (value as Account).id === 'string' &&
    typeof (value as Account).balance === 'number'
  );
}

const body: unknown = await res.json();
if (!isAccount(body)) throw new Error('Unexpected account shape');
body.balance; // Account

Важливо: компілятор не перевіряє, що тіло guard-функції чесне. Якщо guard повертає true для неправильного об'єкта, ви отримаєте брехливий тип. Тому складні перевірки краще генерувати зі схеми.

Assertion function - функція з сигнатурою asserts value is T. Вона нічого не повертає, а кидає помилку, якщо умова не виконана. Після виклику тип звужений.

function assertDefined<T>(v: T, msg: string): asserts v is NonNullable<T> {
  if (v === undefined || v === null) throw new Error(msg);
}

const row = rows.find(r => r.txId === txId);
assertDefined(row, `Transaction ${txId} not found`);
row.amount; // без '?' і без '!'

На практиці для валідації відповідей API я використовую бібліотеку схем, наприклад zod: schema.parse(body) перевіряє дані в рантаймі і повертає значення з правильним типом, а тип виводиться зі схеми через z.infer. Так тип і перевірка не розходяться.

На співбесіді: Type guard - це функція з value is T, після якої TS звужує тип, а assertion function з asserts кидає помилку і звужує тип далі по коду. Компілятор не перевіряє їхню логіку, тому для відповідей API я віддаю перевагу валідації схемою, з якої виводиться тип.