08 · JavaScript/TypeScript для AQA
TypeScript: типи, інтерфейси та строгий режим
Зміст
Playwright, CodeceptJS, Cypress — сучасні фреймворки автоматизації мають першокласну підтримку TypeScript і документують свій API в термінах типів. Коли ти набираєш page.getByRole(...), редактор підказує, які ролі метод приймає й що поверне, а рядок page.getByRoel(...) підкреслює червоним ще до запуску тесту. Це і є головна цінність TypeScript для AQA: типову помилку видно на етапі написання, а не через двадцять хвилин прогону в CI, коли тест упав із TypeError: undefined is not a function десь усередині чужого коду.
TypeScript — це надбудова (superset) над JavaScript: будь-який валідний JS-код є валідним TS-кодом, плюс синтаксис, щоб описати типи значень. Ключове, що треба зрозуміти з першого дня: типи існують лише під час компіляції. Перед запуском TypeScript перетворюється на звичайний JavaScript, і всі анотації стираються (type erasure) — у рантаймі жодних типів немає. Тому TS ловить не всі помилки, а лише ті, які видно зі статичного аналізу коду. Це одночасно й суперсила, і межа інструмента, і майже гарантоване питання на співбесіді.
Навіщо AQA взагалі типізація
Автотест — це код, який читають і правлять місяцями. Типи дають три речі, за які тестова команда їх і любить.
Перше — автодоповнення й документація, що не бреше. Коли APIRequestContext типізований, редактор сам показує список методів і форму відповіді; не треба тримати документацію в сусідній вкладці. Друге — рефакторинг без страху: переназвав поле у фікстурі — компілятор одразу підсвітив усі місця, де на нього спиралися. Третє — клас помилок, який зникає повністю: одрук у назві поля, забутий await (тип Promise замість значення), спроба звернутися до властивості того, що може бути undefined.
Важлива чесність про межі. TypeScript не перевіряє, що застосунок повертає саме те, що ти описав. Якщо API за контрактом віддає { id: number }, а насправді прислав { id: "42" }, TS цього не побачить — бо перевірка типів закінчилась на етапі компіляції, а дані приходять у рантаймі. Тому типізація не замінює перевірок у тесті; вона лише прибирає помилки в самому тестовому коді. Валідацію реальної відповіді роблять окремо — рантайм-схемою (наприклад, zod чи ajv), і це вже тема глави про генерики та звуження.
Анотації і виведення типів
Анотація — це явне зазначення типу через двокрапку: let count: number = 0. Але писати її всюди не треба, і це перше, що дивує новачків. TypeScript виводить (inference) тип із контексту сам.
let title = "Login page"; // TS сам виводить: string
const retries = 3; // виводить: 3 (літеральний тип, бо const)
const url = new URL("https://example.com"); // виводить: URL
Тому анотацію ставлять там, де виведення недостатнє або небезпечне: у параметрах функцій (їх TS вивести не може) і там, де хочеш зафіксувати ширший контракт, ніж початкове значення.
// Параметри анотуємо завжди; тип повернення TS виведе сам
function buildUrl(path: string, page: number): string {
return `${path}?page=${page}`;
}
Практичне правило для тестового коду: анотуй межі — параметри й публічні сигнатури функцій, форму фікстур і тестових даних. Локальні змінні лиши на виведення; зайві анотації лише засмічують і застарівають. Різниця між let і const тут теж працює на рівні типів: const status = 200 має тип 200 (літерал), а let status = 200 — ширший тип number, бо змінну можна переприсвоїти.
Базові типи, масиви, кортежі, обʼєкти
Примітивні типи в TS збігаються з примітивами JavaScript (їх детально розбирає глава «Змінні, типи даних і приведення типів»): string, number, boolean, null, undefined, bigint, symbol. Плюс кілька суто TS-типів: any, unknown, never, void (типовий результат функції, що нічого не повертає), object.
Одразу застереження про регістр: пиши string і number з малої. String, Number, Boolean з великої — це обʼєкти-обгортки, майже завжди помилка.
Масиви описують двома взаємозамінними способами: number[] або Array<number>. Обидва означають масив однорідних елементів довільної довжини.
const ids: number[] = [1, 2, 3];
const roles: Array<string> = ["admin", "user"];
Кортеж (tuple) — масив фіксованої довжини, де тип кожної позиції відомий заздалегідь. Це не те саме, що масив: [string, number] — це рівно два елементи, спершу рядок, потім число.
// Пара «ключ — значення» або результат виду [дані, помилка]
const header: [string, string] = ["Content-Type", "application/json"];
Кортежі корисні там, де функція повертає кілька значень, які потім деструктуруються за позицією. У тестовому коді трапляються не щодня, але їх люблять питати, бо різницю «масив проти кортежу» одразу видно, хто розуміє систему типів.
Обʼєктний тип описує форму обʼєкта — які поля й якого типу.
function assertUser(user: { id: number; name: string; isActive: boolean }) {
// ...
}
Писати таку форму інлайн щоразу незручно, тому її виносять в іменований тип — і тут постає головна розвилка глави.
type проти interface
Це один із найчастіших запитань співбесіди, і чесна відповідь коротка: для опису форми обʼєкта вони майже взаємозамінні. Обидва варіанти нижче роблять те саме.
interface User {
id: number;
name: string;
isActive: boolean;
}
type UserAlias = {
id: number;
name: string;
isActive: boolean;
};
Різниця — не в цьому прикладі, а в тому, що вміє кожен.
type (псевдонім типу, type alias) — це імʼя для будь-якого типу, не лише обʼєкта: union, кортеж, примітив, функція. Тільки через type можна написати type HttpMethod = "GET" | "POST" або type Id = string | number. Розширюють псевдонім через перетин: type Admin = User & { permissions: string[] }.
interface описує лише форму обʼєкта (чи функції/класу). Розширюють через extends. Головна ексклюзивна здатність — злиття оголошень (declaration merging): два interface з однаковим імʼям автоматично зливаються в один. Саме тому фреймворки використовують interface для типів, які користувач має доповнювати — наприклад, кастомні фікстури Playwright або матчери. Псевдонім type так не вміє: два type з однаковим імʼям — це помилка «повторне оголошення».
| Здатність | interface | type |
|---|---|---|
| Форма обʼєкта | Так | Так |
| Union, кортеж, примітив, функція | Ні | Так |
| Розширення | extends | перетин & |
| Declaration merging | Так | Ні |
| Типове призначення в тесті | публічні контракти, розширювані типи | union/literal, локальні аліаси |
Практична порада, яку дає й офіційна документація: бери interface, поки не знадобиться щось, чого він не вміє — тоді переходь на type. У тестовому коді це виглядає так: форму API-відповіді чи Page Object описуй interface, а type тримай для union літералів (методи, статуси, ролі) і локальних скорочень.
Union і literal types
Union type (обʼєднання) — значення одного з кількох типів, через вертикальну риску: string | number. Це щоденний інструмент, бо реальні дані рідко бувають одного типу. Функція приймає id, який може бути числом або рядком, — union це прямо описує.
Literal type (літеральний тип) — не «якийсь рядок», а конкретне значення як тип. type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" означає, що змінна може бути тільки одним із цих чотирьох рядків, і жодним іншим.
type Status = "passed" | "failed" | "skipped";
function report(status: Status) {
// ...
}
report("passed"); // OK
report("blocked"); // Помилка компіляції: немає такого варіанта
Це надзвичайно корисно в автоматизації: замість «магічних рядків», у яких легко зробити одрук, компілятор гарантує, що ти передав дозволене значення, а редактор пропонує варіанти автодоповненням. Union літералів працює й для чисел (type Retry = 0 | 1 | 2), і для змішаних типів.
Коли union складається з обʼєктів, які мають спільне поле-дискримінатор, TS уміє звужувати тип за перевіркою цього поля (discriminated union) — але механіка звуження це вже територія наступної глави про генерики та narrowing.
any, unknown, never
Три спеціальні типи, які плутають найчастіше, — а різниця між ними — улюблене питання інтервʼюера, бо відповідь показує, чи людина розуміє, навіщо взагалі типізація.
any — вимкнути перевірку типів для цього значення. З any можна робити що завгодно: викликати як функцію, звертатися до будь-яких полів, присвоювати будь-куди. Компілятор мовчить. Проблема в тому, що any «заразний»: він протікає далі кодом і тихо вимикає перевірки там, куди потрапив. По суті кожне any — це діра в типізації, за яку ти сам відповідаєш. У тестовому коді any найчастіше зʼявляється як «тимчасове рішення», що лишається назавжди.
unknown — безпечний аналог any. Сюди теж можна покласти будь-що, але, на відміну від any, зробити з цим значенням нічого не можна, поки не звузиш тип перевіркою. Це саме те, що треба для непередбачуваних даних: тіло await response.json(), розпарсений JSON, зовнішній вхід.
const data: unknown = await response.json();
// data.id — помилка: обʼєкт типу unknown, спершу переконайся, що це те, що думаєш
if (typeof data === "object" && data !== null && "id" in data) {
// тут TS уже дозволить безпечніші дії
}
Правило просте: якщо тягне написати any, майже завжди правильна відповідь — unknown плюс явна перевірка.
never — тип, який не має жодного значення. Це «дно» системи типів. Він природно виникає у двох місцях: функція, що завжди кидає виняток або нескінченно крутиться (нічого не поверне), і недосяжна гілка коду. Друге застосування практичне: never дає перевірку на вичерпність (exhaustiveness) switch за union літералів — якщо додати новий варіант і забути гілку, компілятор скаже про це через never.
| Тип | Що означає | Коли доречний |
|---|---|---|
any | перевірку вимкнено | майже ніколи; технічний борг |
unknown | будь-що, але спершу звузь | зовнішні/непередбачувані дані |
never | значення не існує | недосяжний код, вичерпність switch |
Опціональність, strictNullChecks і оператор !
Знак питання ? після імені поля або параметра робить його опціональним: поля може не бути взагалі, а до його типу додається undefined (name?: string дає тип string | undefined).
interface RequestOptions {
method: string;
timeout?: number; // можна не передавати
}
Але вся вага цієї теми — у прапорці strictNullChecks, який відповідає на питання «чи можна класти null і undefined куди завгодно».
Коли він вимкнений (стара, небезпечна поведінка), null і undefined сумісні з будь-яким типом. Змінна типу string спокійно приймає null, а компілятор мовчить — рівно до того моменту, коли в рантаймі впаде класичне Cannot read properties of null. TypeScript тут не допомагає взагалі.
Коли ввімкнений (правильна поведінка, частина сімейства strict), null і undefined — окремі типи. Значення, яке може бути «нічим», доводиться описувати явно (string | null), і компілятор змушує обробити цей випадок, перш ніж використати значення.
// strictNullChecks: true
const cell = await page.locator(".total").textContent(); // тип: string | null
// cell.trim() — помилка: cell може бути null
if (cell !== null) {
cell.trim(); // тут TS уже впевнений, що string
}
Саме тому strictNullChecks (і ширше — strict: true у tsconfig) — не примха, а те, заради чого TypeScript узагалі беруть: він перетворює найпоширеніший клас рантайм-падінь на помилку компіляції. Механіку tsconfig і всі прапорці строгого режиму розбирає окрема глава «tsconfig і TypeScript у тестовому проєкті»; тут важливо запамʼятати сам ефект.
І тут зʼявляється оператор ! (non-null assertion) — і це головна пастка глави. Знак оклику після значення каже компілятору: «повір, тут не буде null чи undefined».
const cell = await page.locator(".total").textContent();
cell!.trim(); // компілятор мовчить...
Виглядає як зручний спосіб позбутися червоного підкреслення. А насправді ! нічого не перевіряє в рантаймі — він лише вимикає перевірку компілятора. Якщо textContent() реально поверне null, тест упаде з тим самим Cannot read properties of null, від якого strictNullChecks мав тебе вберегти. Тобто ! не розвʼязує проблему, а замітає її під килим і повертає тебе в світ без типів. У тестовому коді це особливо підступно: null з локатора — це часто справжній сигнал, що елемента немає або сторінка не догрузилась, а ! цей сигнал глушить. Майже завжди правильна заміна — явна перевірка (if), а в Playwright — краще взагалі не брати текст руками, а користуватися web-first асертами на кшталт await expect(locator).toHaveText(...), які самі чекають і не повертають null.
enum проти union літералів
enum — спосіб дати іменам набір повʼязаних констант. Виглядає звично для тих, хто прийшов з Java чи C#.
enum Status {
Passed,
Failed,
Skipped,
}
Але в TypeScript є нюанс, через який enum сьогодні радять з обережністю. На відміну від решти системи типів, звичайний enum не стирається — він компілюється в реальний обʼєкт у JavaScript, який лишається в рантаймі. Числовий enum ще й створює зворотний мапінг (і за іменем, і за числом), що іноді дивує. Порівняй із union літералів — той існує лише як тип і зникає при компіляції безслідно.
Для більшості випадків у тестовому коді union літералів робить те саме, але простіше й без рантайм-сліду:
// Замість enum — union літералів
type Status = "passed" | "failed" | "skipped";
Різниця на практиці:
| Аспект | enum | union літералів |
|---|---|---|
| Слід у рантаймі | так, реальний обʼєкт | немає, стирається |
| Значення | автоматичні (0,1,2) або задані | самі рядки/числа |
| Автодоповнення | так | так |
| Ітерувати значення в коді | легко (обʼєкт існує) | треба окремий масив |
| Складність для новачка | більше сюрпризів | простіше |
Коли enum усе ж виправданий: коли значення справді треба перебирати в рантаймі як колекцію. Інакше — типовий сучасний вибір для AQA — type з union літералів (за потреби доповнений as const-обʼєктом, якщо значення потрібні й у коді). Це не догма, а орієнтир; головне — розуміти, що enum лишає слід у зібраному коді, а union — ні.
Типові помилки
Виглядає як «TypeScript перевірить мій API» — а насправді типи зникають до рантайму. Анотація response: { id: number } не гарантує, що сервер прислав число. Реальні дані валідують рантайм-схемою, не типом.
Виглядає як «! виправив помилку» — а насправді лише вимкнув перевірку. Non-null assertion не додає перевірки в рантаймі; він прибирає червоне підкреслення й повертає ризик null-падіння. Заміна — if-перевірка або асерт, що чекає елемент.
Виглядає як «any розблокував код» — а насправді відкрив діру, що протікає далі. З any компілятор мовчить скрізь, куди значення потрапить. Для непередбачуваних даних правильний тип — unknown із наступним звуженням.
Виглядає як «const arr — незмінний масив» — а насправді const фіксує лише звʼязок. const ids = [1]; ids.push(2) — валідно; заборонене тільки переприсвоєння самої змінної. Незмінність описують окремо (readonly, as const).
Виглядає як «treба анотувати кожну змінну» — а насправді зайві анотації шкодять. TS виводить локальні типи сам; анотуй межі (параметри, публічні сигнатури, форму даних), а не кожен рядок.
Виглядає як «enum — безкоштовний, як тип» — а насправді він лишає обʼєкт у зібраному JS. Для простого набору варіантів union літералів легший і зникає при компіляції.
Підсумок
- Типи TypeScript існують лише під час компіляції й стираються перед запуском; вони ловлять помилки у тестовому коді, але не перевіряють реальні дані застосунку.
interfaceіtypeмайже взаємозамінні для форми обʼєкта; тількиtypeописує union/літерали/кортежі, тількиinterfaceуміє declaration merging.anyвимикає перевірку й «заражає» код; для непередбачуваних даних бериunknownі звужуй;never— тип без значень (недосяжний код, вичерпність).strictNullChecksперетворює найпоширеніший рантайм-збій (null/undefined) на помилку компіляції; оператор!цю страховку тихо вимикає, нічого не перевіряючи.- Union літералів — типовий сучасний вибір замість enum: простіший і без сліду в рантаймі; enum лишає реальний обʼєкт у зібраному JavaScript.
Що питають на співбесіді
- «Навіщо TypeScript, якщо є JavaScript?» — інтервʼюер перевіряє, чи розумієш ти цінність (помилки на етапі написання, автодоповнення, безпечний рефакторинг) і, головне, межу: типи стираються, рантайм даних вони не перевіряють.
- «Чим
typeвідрізняється відinterface?» — слабка відповідь «нічим» або «interface для обʼєктів». Сильна: обидва описують форму обʼєкта, але тількиtype— union/літерали/кортежі, тількиinterface— declaration merging; звідси й правило вибору. - «
anyпротиunknown?» — класика на розуміння безпеки типів. Очікують:anyвимикає перевірку скрізь,unknownвимагає звузити тип перед використанням; томуunknownмайже завжди краще. - «Що таке
strictNullChecksі навіщо?» — дивляться, чи ти розумієш, що без ньогоnull/undefinedприсвоюються будь-куди, а з ним компілятор змушує обробити «порожнечу». Бонусом — чому!(non-null assertion) — це не рішення, а маскування. - «enum чи union літералів?» — перевіряють обізнаність із тим, що enum лишає слід у рантаймі, а union стирається; і чи можеш ти назвати, коли enum усе ж доречний (перебір значень у коді).
- «Чи впіймає TypeScript, що API повернув рядок замість числа?» — пастка на плутанину компіляції й рантайму. Правильно: ні, для цього потрібна рантайм-валідація схемою.
Джерела
- TypeScript Handbook — Everyday Types — базові типи, масиви, кортежі, обʼєктні типи,
any/unknown, union і literal types. - TypeScript Handbook — Object Types (Interfaces vs Type Aliases) — офіційне порівняння
interfaceіtype, розширення, опціональні поля. - tsconfig reference — strictNullChecks — точна поведінка прапорця й місце в сімействі
strict. - TypeScript Handbook — Enums — рантайм-семантика enum і застереження щодо неї.
- Playwright — TypeScript support — як TS-типи виглядають у реальному тестовому фреймворку.
Ця глава — про статичні гарантії, які TypeScript дає ще до запуску. Наступний крок — навчити компілятор працювати з даними, форма яких відома лише в рантаймі: генерики, звуження типів і утилітарні типи розбирає глава «TypeScript: генерики, утилітарні типи та звуження».
Що таке TypeScript і чому його називають надбудовою над JavaScript?
TypeScript додає до JavaScript шар статичних типів, і будь-який робочий JS-код автоматично лишається валідним TS-кодом. Саме тому його звуть надбудовою (superset): ти не переходиш на іншу мову, а дописуєш до знайомого коду анотації, що описують, якої форми очікуєш значення. Перед запуском компілятор звіряє ці описи, після чого перетворює файл на звичайний JavaScript — виконується вже чистий JS. Для тестувальника головна вигода в тому, що одрук у назві методу чи поля видно в редакторі одразу, а не через падіння через пів години в CI. Тобто TS — це інструмент раннього вилову помилок у самому тестовому коді, а не нова платформа виконання.
Навіщо автотестам типізація, які проблеми вона знімає?
Автотест живе роками, його читають і правлять різні люди, тож ціна одруку висока. Типи дають три конкретні речі: чесне автодоповнення (редактор сам показує методи типізованого APIRequestContext і форму відповіді), безстрашний рефакторинг (переназвав поле у фікстурі — компілятор підсвітив усі місця, що на нього спиралися) і цілий клас помилок, який просто зникає. Сюди входять забутий await (де замість значення лишається Promise), звернення до поля того, що може бути undefined, і передавання не того типу аргумента. Важливо розуміти межу: типи прибирають баги в коді тесту, але не перевіряють, чи застосунок реально повернув те, що ти описав. Тому типізація доповнює перевірки, а не заміняє їх.
Що означає, що типи існують лише під час компіляції?
Це і є стирання типів (type erasure): усі анотації живуть тільки на етапі перевірки, а перед запуском компілятор їх викидає й лишає голий JavaScript. У рантаймі жодного поняття «тип» уже немає — Node виконує код так, наче TypeScript ніколи й не було. Практичний наслідок один і болючий: TS ловить лише те, що видно зі статичного аналізу тексту програми, і безсилий проти всього, що визначається під час виконання. Не можна, наприклад, у if спитати «а це справді User?» через сам тип — для рантайм-перевірки потрібен звичайний JS-код (typeof, in, перевірка полів). Розуміння цієї межі майже завжди питають, бо воно відділяє тих, хто вивчив синтаксис, від тих, хто зрозумів модель.
Що таке виведення типів і де тоді потрібні явні анотації?
Виведення типів (type inference) — це коли TypeScript визначає тип сам із контексту, без твоєї підказки: let title = "Login" дає string, а const retries = 3 — літеральний тип 3. Через це анотувати кожен рядок не треба й навіть шкідливо — зайві анотації засмічують код і застарівають. Ставити явний тип варто там, де виведення не спрацьовує або небезпечне: у параметрах функцій (їх компілятор угадати не може) і там, де хочеш зафіксувати ширший чи чіткіший контракт, ніж дає початкове значення. Практичне правило для тестів — анотуй межі: параметри, публічні сигнатури функцій, форму фікстур і тестових даних, а локальні змінні лишай на виведення. Так типів рівно стільки, скільки треба, і жодного зайвого шуму.
Чому const status = 200 і let status = 200 мають різні типи?
Тому що виведення враховує можливість переприсвоєння. У const status = 200 значення змінити не можна ніколи, тож TypeScript звужує тип до літерального 200 — це точно двохсотка й нічого іншого. У let status = 200 змінну дозволено переприсвоїти будь-яким числом, тому компілятор виводить ширший тип number. Різниця не косметична: літеральний тип від const дозволяє передати значення туди, де очікують саме 200, а number — ні. На цьому ж механізмі тримаються union літералів: щоб масив рядків став набором конкретних варіантів, а не просто string[], застосовують as const. Плутанина «чому мій const-рядок не пасує до union літералів, а let тим паче» — типове джерело здивування новачків.
Чим кортеж відрізняється від звичайного масиву?
Масив (number[] або Array<number>) — це однорідні елементи довільної довжини: усі одного типу, скільки їх — невідомо. Кортеж (tuple) — навпаки, фіксована довжина, де тип кожної позиції відомий заздалегідь: [string, number] означає рівно два елементи, спершу рядок, потім число. Тобто масив описує «багато однакового», а кортеж — «конкретна структура за позиціями». Кортежі зручні, коли функція повертає кілька значень для деструктуризації за позицією — наприклад, пара [дані, помилка]. У щоденному тестовому коді вони трапляються нечасто, але їх люблять питати саме тому, що по відповіді одразу видно, чи людина розуміє різницю між довжиною й формою.
Чому string пишуть з малої літери, а String — майже завжди помилка?
string, number, boolean з малої — це примітивні типи TypeScript, і саме їх треба вживати. String, Number, Boolean з великої — це типи обʼєктів-обгорток навколо примітивів, які на практиці майже ніколи не потрібні. Різниця не суто стилістична: обгортка не взаємозамінна з примітивом там, де очікують примітив, тож String замість string тягне дивні помилки сумісності типів. Редактор зазвичай і сам підказує малу форму. Просте правило — у власному коді завжди пиши примітиви з малої, а велику форму сприймай як сигнал, що хтось помилився.
Чим type відрізняється від interface?
Це один із найчастіших запитів співбесіди, і слабка відповідь «нічим» або «interface лише для обʼєктів» одразу видає поверхове знання. Чесно: для опису форми обʼєкта вони майже взаємозамінні. Різниця в тому, що кожен уміє ексклюзивно. type — це імʼя (псевдонім, type alias) для будь-якого типу: тільки через нього описують union, кортеж, примітив чи функцію, наприклад type Method = "GET" | "POST"; розширюють його перетином &. interface описує лише форму обʼєкта, розширюється через extends і вміє те, чого не вміє type, — злиття оголошень (declaration merging). Звідси й практичне правило: бери interface за замовчуванням, а type — коли треба union, літерали, кортеж чи локальне скорочення.
Що таке declaration merging і навіщо це фреймворкам?
Declaration merging (злиття оголошень) — це здатність interface: два інтерфейси з однаковим імʼям TypeScript автоматично зливає в один сумарний тип. Псевдонім type так не вміє — два type з однаковим імʼям дадуть помилку повторного оголошення. На перший погляд дивна фіча, але вона критична для розширюваних API: фреймворк оголошує interface для типу, який користувач має доповнювати своїми полями, і твоє доповнення просто зливається з базовим. Класика для AQA — кастомні фікстури Playwright: ти розширюєш інтерфейс фікстур своїми, і типізація бачить і рідні, і твої. Саме тому такі точки розширення роблять на interface, а не на type — щоб їх можна було догорнути ззовні.
Що таке union і literal types і навіщо вони в автотестах?
Union type (обʼєднання) — це значення одного з кількох типів через вертикальну риску: string | number. Він потрібен щодня, бо реальні дані рідко одного типу — той самий id буває і числом, і рядком. Literal type (літеральний тип) — це не «якийсь рядок», а конкретне значення, підняте до рівня типу: type Status = "passed" | "failed" | "skipped" дозволяє тільки ці три рядки й жоден інший. У парі вони дають потужний ефект в автоматизації: замість «магічних рядків», де легко зробити одрук, компілятор гарантує дозволене значення, а редактор пропонує варіанти автодоповненням. Передав report("blocked") замість дозволеного статусу — помилка ще до запуску. Працює це і для чисел (0 | 1 | 2), і для змішаних наборів.
У чому різниця між any і unknown?
Класичне питання на розуміння безпеки типів. any вимикає перевірку типів для значення повністю: з ним можна робити будь-що — викликати як функцію, лізти в будь-які поля, присвоювати будь-куди, і компілятор мовчить. Проблема в тому, що any заразний: він протікає далі кодом і тихо глушить перевірки скрізь, куди потрапляє, тож кожне any — це діра в типізації під твою відповідальність. unknown — безпечний аналог: сюди теж можна покласти будь-що, але зробити з цим значенням нічого не вийде, поки не звузиш тип явною перевіркою. Саме unknown пасує до непередбачуваних даних — тіла await response.json(), розпарсеного JSON, зовнішнього вводу. Правило коротке: тягне написати any — майже завжди правильна відповідь unknown плюс перевірка.
Що таке тип never і де він виникає?
never — це тип, що не має жодного значення, «дно» системи типів; у нього неможливо нічого покласти. Він природно зʼявляється у двох місцях. Перше — функція, що ніколи не повертає керування нормально: завжди кидає виняток або крутиться в нескінченному циклі, тож і повертати їй нема чого. Друге, практичніше, — недосяжна гілка коду: якщо ти вичерпав усі варіанти union літералів у switch, у гілці default тип звужується до never. Це дає перевірку на вичерпність (exhaustiveness): додав новий варіант у союз і забув обробити — компілятор поскаржиться, бо значення більше не влазить у never. Тобто never перетворює «забув гілку» на помилку компіляції, і це його головна користь на практиці.
Що робить прапорець strictNullChecks і навіщо він?
strictNullChecks вирішує, чи можна класти null і undefined куди завгодно. Коли він вимкнений (стара небезпечна поведінка), ці значення сумісні з будь-яким типом: змінна string спокійно приймає null, компілятор мовчить — рівно до рантайму, де вилітає класичне Cannot read properties of null. Коли ввімкнений (правильний режим, частина сімейства strict), null і undefined стають окремими типами: якщо значення може бути «нічим», це доводиться описати явно (string | null), і компілятор змушує обробити цей випадок, перш ніж чіпати значення. По суті прапорець перетворює найпоширеніший клас рантайм-падінь на помилку ще на етапі перевірки — саме заради цього TypeScript часто й беруть. У тестах це особливо цінно: null з локатора перестає бути тихою міною.
Що таке оператор ! (non-null assertion) і чому це пастка?
Знак оклику після значення (cell!.trim()) каже компілятору: «повір, тут не буде null чи undefined». Виглядає як зручний спосіб прибрати червоне підкреслення, і саме тому він небезпечний. ! нічого не перевіряє в рантаймі — він лише вимикає перевірку компілятора й повертає тебе в світ без типів. Якщо значення реально виявиться null, тест упаде з тим самим Cannot read properties of null, від якого strictNullChecks мав тебе вберегти. У тестовому коді це підступно вдвічі: null з локатора часто не випадковість, а справжній сигнал, що елемента немає або сторінка не догрузилась, — а ! цей сигнал глушить. Майже завжди правильна заміна — явна перевірка через if, а в Playwright краще взагалі не діставати текст руками, а користуватися web-first асертами (await expect(locator).toHaveText(...)), які самі чекають і не повертають null.
Що робить знак ? після імені поля чи параметра?
Знак питання робить поле або параметр опціональним: його може не бути взагалі, а до типу автоматично додається undefined. Тобто timeout?: number — це фактично number | undefined, і викликач має право не передавати значення. Це зручно для конфігів і опцій, де частина полів необовʼязкова. Але за ввімкненого strictNullChecks опціональність не безкоштовна: перш ніж використати таке поле, компілятор змусить пересвідчитися, що воно не undefined. Тому опціональні поля — це не «поле, яке я іноді ігнорую», а «поле, до якого домальовано undefined, і його треба обробити». Плутанина між «поля немає» і «поле є, але дорівнює undefined» — типова тонкість, яку інколи й перевіряють.
Чим enum відрізняється від union літералів і що обрати в тестовому коді?
enum дає імена набору повʼязаних констант і звичний вигляд для тих, хто прийшов з Java чи C#. Але в TypeScript у нього є нюанс: на відміну від решти системи типів, звичайний enum не стирається — він компілюється в реальний обʼєкт, що лишається в рантаймі, а числовий ще й додає зворотний мапінг (і за іменем, і за числом), який іноді дивує. Union літералів натомість існує лише як тип і зникає при компіляції безслідно. Для більшості випадків в автоматизації union робить те саме, але легший і без сліду в зібраному JS. Enum виправданий тоді, коли значення справді треба перебирати в рантаймі як колекцію (обʼєкт для цього вже існує); в решті випадків типовий сучасний вибір — type з union літералів, за потреби доповнений as const-обʼєктом.
Чи впіймає TypeScript, що API повернув рядок замість числа?
Ні — і це улюблена пастка на плутанину компіляції та рантайму. Якщо ти описав відповідь як { id: number }, а сервер прислав { id: "42" }, TypeScript цього не побачить: його перевірка закінчилась на етапі компіляції, а дані приходять під час виконання, коли типів уже немає. Анотація описує твоє очікування, а не гарантує реальність — вона нічого не звіряє з тим, що прилетіло по мережі. Щоб упіймати таку розбіжність, потрібна рантайм-валідація схемою (zod, ajv тощо), яка перевіряє справжню відповідь під час виконання. Тому типізація й рантайм-перевірки — це два різні шари: перший стереже код тесту, другий — контракт застосунку, і один одного вони не заміняють.
Три ситуації з тестового коду, де типи вирішують, зелений тест чи тихий баг: як оператор ! глушить справжній сигнал у Playwright, як обрати між type та interface для тестових типів, і чому await response.json() треба зустрічати типом unknown, а не any. Скрізь — що дивитися і чому.
Кейс 1. Оператор ! ховає справжню причину падіння
Тест дістає підсумкову суму з таблиці й порівнює її. textContent() у Playwright повертає string | null, тож за ввімкненого strictNullChecks компілятор підкреслює cell.trim(): «cell може бути null». Найшвидший спосіб прибрати червоне — дописати !. Саме так у код і потрапляє міна:
// погано: ! вимикає перевірку, але нічого не гарантує
const cell = await page.locator('.total').textContent();
const total = cell!.trim(); // якщо textContent поверне null — TypeError у рантаймі
Проблема не в тому, що впаде тест, а в тому, що він упаде не там і не з тим повідомленням. null тут — це не випадковість, а сигнал: рядок .total не знайдено, сторінка не догрузилась або елемент іще порожній. Оператор ! цей сигнал глушить і перетворює осмислений діагноз на голе Cannot read properties of null десь нижче за стеком.
Явна перевірка повертає сигнал на місце:
const cell = await page.locator('.total').textContent();
if (cell === null) {
throw new Error('Елемент .total не знайдено або порожній — перевір локатор і стан сторінки');
}
const total = cell.trim(); // тут TS упевнений, що це string
Але в Playwright є ще краще рішення — взагалі не діставати текст руками. Web-first асерт сам чекає на елемент і не повертає null:
// найнадійніше: асерт з автоочікуванням, жодного null у грі
await expect(page.locator('.total')).toHaveText('1 200 грн');
Що дивитися і чому:
!не додає перевірки — він її прибирає. Це не «я впевнений, що не null», а «не перевіряй за мене». Компілятор замовкає, ризик лишається цілим.nullз локатора майже завжди інформативний. Він каже про стан сторінки, а не про примху типів; тест має впасти з цим діагнозом, а не з абстрактнимTypeError.- Web-first асерти прибирають саме джерело
null.toHaveText,toBeVisibleчекають на елемент і не змушують тебе тримати руками значення, яке може виявитися порожнім.
Кейс 2. type чи interface для тестових типів — таблиця рішень
Питання «type чи interface» на практиці зводиться не до смаку, а до того, що з типом робитимуть далі. Ось типові тестові сутності й обґрунтований вибір:
| Що описуємо | Вибір | Чому |
|---|---|---|
Форма API-відповіді ({ id, email, role }) | interface | Публічний контракт, який читають і розширюють; extends під спільні поля |
| Тип фікстур Playwright, який доповнюєш своїми | interface | Потрібне declaration merging — твої фікстури зливаються з базовими |
| Набір статусів/методів/ролей як union літералів | type | Union літералів interface описати не вміє |
Кортеж результату ([data, error]) | type | Кортеж — тільки через type |
| Локальне скорочення для громіздкого union | type | Аліас на будь-який тип, а не лише на обʼєкт |
Практичне правило під це: форму обʼєкта (відповідь, Page Object, конфіг) описуй interface за замовчуванням, а type тримай для union літералів і локальних аліасів. Приклад із життя фікстур, де вибір змушений:
// interface — бо його треба ДОПОВНИТИ ззовні; type тут не спрацював би
import { test as base } from '@playwright/test';
interface AppFixtures {
loginAsAdmin: () => Promise<void>;
}
// union літералів — тільки type
type UserRole = 'admin' | 'editor' | 'viewer';
interface ApiUser {
id: number;
email: string;
role: UserRole; // union усередині форми обʼєкта
}
Що дивитися і чому:
- Declaration merging — єдиний по-справжньому змушений критерій. Якщо тип мають доповнювати ззовні (фікстури, матчери), це
interface, і крапка. - Union, літерали, кортеж — територія
type. Спроба описати їх інтерфейсом просто не скомпілюється. - «Взаємозамінні» стосується лише простої форми обʼєкта. Щойно зʼявляється розширюваність або union — вибір перестає бути справою смаку.
Кейс 3. await response.json() — чому unknown, а не any
Тіло відповіді приходить у рантаймі, коли типів уже немає, тож TypeScript не знає його форми. Спокуса — приписати any й забути; насправді це відчиняє діру, що протікає далі кодом:
// погано: any вимикає перевірки скрізь, куди потрапить data
const data: any = await response.json();
data.usr.id; // одрук usr замість user — компілятор мовчить, впаде в рантаймі
unknown змушує спершу переконатися, що прийшло те, що ти думаєш, і аж потім чіпати поля:
const data: unknown = await response.json();
// звуження: перевіряємо форму, перш ніж довіряти їй
if (
typeof data === 'object' && data !== null &&
'user' in data && typeof (data as { user: unknown }).user === 'object'
) {
// тут уже безпечніше працювати з data.user
}
Але ключова думка глибша: навіть акуратне звуження перевіряє форму лише настільки, наскільки ти сам її прописав, і не гарантує контракту. Якщо треба справді впевнитися, що API віддав { id: number }, а не { id: "42" }, потрібна рантайм-схема — TypeScript цього не зловить у принципі:
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), email: z.string() });
const raw = await response.json();
const user = UserSchema.parse(raw); // впаде тут з чітким описом, ЩО саме не так
// user типізований як { id: number; email: string } і вже звірений із реальністю
Що дивитися і чому:
anyзаразний,unknown— ні.anyглушить перевірки в усьому ланцюжку,unknownтримає значення «замкненим», поки ти не доведеш його форму.- Звуження типу — це рантайм-код, а не тип.
typeof,in, перевірка полів працюють під час виконання; сам тип нічого не перевіряє. - Типи не заміняють валідацію контракту. Форму реальної відповіді звіряють схемою (zod/ajv); анотація описує очікування, схема — перевіряє факт.
Природа TypeScript
- Можу пояснити, чому TypeScript — надбудова (superset): будь-який валідний JS є валідним TS, типи лише додаються зверху.
- Розумію стирання типів (type erasure): анотації живуть тільки під час компіляції й зникають перед запуском, у рантаймі типів немає.
- Знаю межу інструмента: TS ловить помилки в самому тестовому коді, але не перевіряє, що застосунок реально повернув описану форму.
Анотації та виведення
- Розумію виведення типів (inference) і чому анотувати кожен рядок шкідливо: TS сам визначає локальні типи з контексту.
- Знаю правило «анотуй межі»: параметри функцій, публічні сигнатури, форму фікстур — а локальні змінні лишай на виведення.
- Можу пояснити, чому
const x = 200має тип200(літерал), аlet x = 200— ширшийnumber.
Базові типи, масиви, кортежі
- Знаю, що примітиви пишуться з малої (
string,number,boolean), аString/Numberз великої — обгортки й майже завжди помилка. - Можу пояснити різницю масив vs кортеж (tuple): масив — багато однакового невідомої довжини, кортеж — фіксована довжина з типом кожної позиції.
type проти interface
- Знаю, що для форми обʼєкта
typeтаinterfaceмайже взаємозамінні, і чесно кажу це замість «interface лише для обʼєктів». - Можу назвати ексклюзив
type: union, літерали, кортеж, примітив, функція — усе це тільки через нього. - Можу назвати ексклюзив
interface: злиття оголошень (declaration merging), через яке фреймворки роблять розширювані типи (кастомні фікстури Playwright); розширюють йогоextends, аtype— перетином&. - Тримаю в голові правило вибору:
interfaceза замовчуванням,type— коли треба щось, чого interface не вміє.
Union і literal types
- Розумію union type (
string | number) як «значення одного з кількох типів» і навіщо він для неоднорідних даних. - Можу пояснити literal type (
"passed" | "failed") і чому він рятує від «магічних рядків»: одрук стає помилкою компіляції.
any, unknown, never
- Знаю різницю
anyvsunknown:anyглушить перевірку й «заражає» код далі, аunknownвимагає звузити тип перед використанням — тому саме він пасує доawait response.json()і зовнішнього вводу. - Можу пояснити
neverяк тип без значень і його користь — перевірку на вичерпність (exhaustiveness)switchза union літералів.
strictNullChecks і опціональність
- Розумію, що
?робить поле опціональним і фактично додаєundefinedдо його типу (timeout?: number=number | undefined). - Можу пояснити ефект
strictNullChecks:null/undefinedстають окремими типами, і компілятор змушує обробити «порожнечу» перед використанням. - Знаю, чому оператор
!(non-null assertion) — пастка: він вимикає перевірку компілятора, нічого не перевіряючи в рантаймі, і глушить справжній сигналnullз локатора.
enum проти union літералів
- Можу пояснити, чому
enumлишає слід у рантаймі (реальний обʼєкт, а числовий — ще й зворотний мапінг), а union літералів стирається безслідно, і назвати, коли enum усе ж доречний — коли значення треба перебирати в коді як колекцію.
Що означає стирання типів (type erasure) у TypeScript?
Питання
Чому TypeScript — це надбудова (superset) над JavaScript?