vyvchy
    Теми розділу

    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 ловить не всі помилки, а лише ті, які видно зі статичного аналізу коду. Це одночасно й суперсила, і межа інструмента, і майже гарантоване питання на співбесіді.

    Автотест — це код, який читають і правлять місяцями. Типи дають три речі, за які тестова команда їх і любить.

    Перше — автодоповнення й документація, що не бреше. Коли APIRequestContext типізований, редактор сам показує список методів і форму відповіді; не треба тримати документацію в сусідній вкладці. Друге — рефакторинг без страху: переназвав поле у фікстурі — компілятор одразу підсвітив усі місця, де на нього спиралися. Третє — клас помилок, який зникає повністю: одрук у назві поля, забутий await (тип Promise замість значення), спроба звернутися до властивості того, що може бути undefined.

    Важлива чесність про межі. TypeScript не перевіряє, що застосунок повертає саме те, що ти описав. Якщо API за контрактом віддає { id: number }, а насправді прислав { id: "42" }, TS цього не побачить — бо перевірка типів закінчилась на етапі компіляції, а дані приходять у рантаймі. Тому типізація не замінює перевірок у тесті; вона лише прибирає помилки в самому тестовому коді. Валідацію реальної відповіді роблять окремо — рантайм-схемою (наприклад, zod чи ajv), і це вже тема глави про генерики та звуження.

    є помилка типів

    типи ок

    login.spec.ts
    код з типами

    tsc / tsx
    перевірка типів

    Помилка компіляції
    тест не запускається

    login.spec.js
    анотації стерто

    Node виконує JS
    типів уже немає

    є помилка типів

    типи ок

    login.spec.ts
    код з типами

    tsc / tsx
    перевірка типів

    Помилка компіляції
    тест не запускається

    login.spec.js
    анотації стерто

    Node виконує JS
    типів уже немає

    Анотації і виведення типів

    Анотація — це явне зазначення типу через двокрапку: 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 з однаковим імʼям — це помилка «повторне оголошення».

    Здатністьinterfacetype
    Форма обʼєктаТакТак
    Union, кортеж, примітив, функціяНіТак
    Розширенняextendsперетин &
    Declaration mergingТакНі
    Типове призначення в тестіпублічні контракти, розширювані типиunion/literal, локальні аліаси

    Практична порада, яку дає й офіційна документація: бери interface, поки не знадобиться щось, чого він не вміє — тоді переходь на type. У тестовому коді це виглядає так: форму API-відповіді чи Page Object описуй interface, а type тримай для union літералів (методи, статуси, ролі) і локальних скорочень.

    Так

    Ні, це форма обʼєкта

    Так

    Ні

    Що описуємо?

    Union, literal,
    кортеж чи примітив?

    Тільки type

    Тип мають
    доповнювати ззовні?

    interface
    declaration merging

    interface за замовчуванням,
    type — теж норм

    Так

    Ні, це форма обʼєкта

    Так

    Ні

    Що описуємо?

    Union, literal,
    кортеж чи примітив?

    Тільки type

    Тип мають
    доповнювати ззовні?

    interface
    declaration merging

    interface за замовчуванням,
    type — теж норм

    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";

    Різниця на практиці:

    Аспектenumunion літералів
    Слід у рантаймітак, реальний обʼєктнемає, стирається
    Значенняавтоматичні (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 дає ще до запуску. Наступний крок — навчити компілятор працювати з даними, форма яких відома лише в рантаймі: генерики, звуження типів і утилітарні типи розбирає глава «TypeScript: генерики, утилітарні типи та звуження».