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 я віддаю перевагу валідації схемою, з якої виводиться тип.