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

    08 · JavaScript/TypeScript для AQA

    TypeScript: генерики, утилітарні типи та звуження

    Зміст

    У попередній главі типи описували конкретні форми: User, string, "passed" | "failed". Цього досить, поки код не починає повторюватися. Щойно ти пишеш хелпер, який має працювати і з User, і з Order, і з масивом будь-чого, — жорсткий тип стає гальмом: або дублюєш функцію під кожну форму, або тулиш any і втрачаєш усю користь TypeScript. Генерики (generics), утилітарні типи (utility types) і звуження (narrowing) — це рівно той інструментарій, який дає писати один типобезпечний хелпер замість десяти, безпечно розбирати unknown з мережі й тримати типи фікстур синхронними з реальними даними без ручного копіювання.

    Для AQA це не академічна вправа. Відповідь API прилітає як unknown або нечесно заанотована any — і треба звузити її до конкретного типу, перш ніж читати поля. Фікстура Playwright має віддати типізований об'єкт, щоб автодоповнення підказувало поля, а не мовчало. Хелпер pick чи білдер тестових даних має приймати будь-яку сутність. На співбесіді середнього рівня це стабільний блок питань: «чим as відрізняється від satisfies», «як написати type guard», «навіщо Partial у білдері». Розберемо механізм, а не список рецептів.

    Генерики: тип як параметр

    Генерик — це тип, параметризований іншим типом. Так само як функція приймає значення-аргумент, генерик приймає тип-аргумент і повертає конкретний тип. Класичний приклад — функція, що повертає те саме, що отримала:

    function first<T>(items: T[]): T | undefined {
      return items[0];
    }
    
    const n = first([1, 2, 3]);        // T = number, тип number | undefined
    const s = first(["a", "b"]);       // T = string, тип string | undefined

    Тут T — параметр типу. TypeScript сам виводить (inference) його зі значення аргументу: передав масив чисел — T став number. Без генерика довелося б або писати first(items: number[]): number, або items: any[], і тоді first(...) повертав би any — тобто типи зникли б рівно там, де вони потрібні. Генерик зберігає зв'язок «що на вході — те й на виході».

    extends як обмеження

    Голий T не має жодних гарантій: усередині функції ти не можеш звертатися до .length чи .id, бо T може бути чим завгодно. Обмеження (constraint) через extends каже: «T — будь-який тип, але точно сумісний із цією формою».

    function byId<T extends { id: number }>(items: T[], id: number): T | undefined {
      return items.find((item) => item.id === id);
    }
    
    byId([{ id: 1, name: "Alice" }], 1);  // ок, у елемента є id
    byId([{ name: "Bob" }], 1);           // помилка: немає поля id

    T extends { id: number } — це не успадкування, а «підтип / сумісний із». Всередині функції тепер можна читати item.id, і водночас на виході зберігається повний тип елемента з усіма його полями, а не обрізаний до { id: number }. Окремо від обмеження генерик може мати тип за замовчуванням: function make<T = string>() використає string, якщо тип-аргумент не передали й вивести його нема звідки.

    Ключова інтуїція: extends у генериках працює як «мінімальний контракт, який тип-аргумент мусить виконати». Це та сама конструкція, на якій побудовано половину утилітарних типів нижче.

    Звуження типів (narrowing)

    Union-тип на кшталт string | number чи User | null описує кілька можливих форм одразу. Але прочитати .toUpperCase() можна лише зі string, а .id — лише з User. Звуження (narrowing) — це процес, у якому TypeScript на підставі перевірок у коді розуміє, що в конкретній гілці тип вужчий за оголошений. Робить це аналіз потоку керування (control flow analysis): після if (typeof x === "string") компілятор знає, що в тілі if змінна x — саме string.

    function format(value: string | number): string {
      if (typeof value === "number") {
        return value.toFixed(2);   // тут value звужено до number
      }
      return value.trim();          // а тут — до string
    }

    Основні механізми звуження:

    ОператорЩо перевіряєКоли доречний
    typeof x === "..."рядок від typeof: string, number, boolean, undefined, object, functionunion примітивів
    x instanceof Cщо об'єкт створено класом C (за прототипом)класи, Error, вбудовані об'єкти
    "key" in xнаявність властивості в об'єктіunion об'єктів різної форми
    truthiness (if (x))відсіює null/undefined/0/""прибрати null з T | null
    порівняння (x === "passed")конкретне літеральне значенняunion літералів
    Array.isArray(x)що значення — масивT | T[]

    typeof покриває примітиви, але для об'єктів безсилий: typeof {} і typeof [] обидва дають "object". Тут вмикається in (перевірка за наявним полем) або instanceof (перевірка за класом). Останній працює лише з тим, що створено через new і має прототип, — тому instanceof годиться для Error чи власних класів, але не для «сирого» JSON-об'єкта, у якого прототип звичайний.

    Окремо варто знати дискримінований union (discriminated union) — коли в кожному варіанті є спільне поле-мітка з літеральним типом:

    type ApiResult =
      | { status: "ok"; data: User }
      | { status: "error"; message: string };
    
    function handle(res: ApiResult) {
      if (res.status === "ok") {
        return res.data;        // звужено до успішного варіанта
      }
      return res.message;       // звужено до помилки
    }

    Перевірка res.status === "ok" звужує весь об'єкт, бо status — спільна мітка. Це найнадійніший спосіб типізувати «або успіх, або помилка», типовий для API-відповідей.

    truthy-гілка порожня

    Так

    Ні

    Змінна типу union
    напр. User | Error | null

    if (!x)

    звужено: прибрали null

    x instanceof Error

    звужено до Error
    читаємо .message

    звужено до User
    читаємо .id

    truthy-гілка порожня

    Так

    Ні

    Змінна типу union
    напр. User | Error | null

    if (!x)

    звужено: прибрали null

    x instanceof Error

    звужено до Error
    читаємо .message

    звужено до User
    читаємо .id

    Кастомні type guards (is)

    Вбудованих typeof/in/instanceof вистачає не завжди. Коли логіка перевірки складніша — наприклад, «це валідна відповідь User, якщо є числовий id і рядковий email» — її виносять у функцію-охоронець (type guard). Її відрізняє особливий тип, що повертається: param is Type замість boolean.

    type User = { id: number; email: string };
    
    function isUser(value: unknown): value is User {
      return (
        typeof value === "object" &&
        value !== null &&
        "id" in value &&
        typeof (value as Record<string, unknown>).id === "number" &&
        typeof (value as Record<string, unknown>).email === "string"
      );
    }
    
    const payload: unknown = await response.json();
    if (isUser(payload)) {
      payload.email;   // тут payload звужено до User
    }

    Предикат value is User — це обіцянка компілятору: «якщо функція повернула true, вважай значення User». Всередині TypeScript тобі не заважає перевіряти руками (бо valueunknown), а на місці виклику довіряє результату й звужує тип. Відповідальність за коректність предиката — на тобі: якщо збрешеш у тілі isUser, компілятор мовчки повірить, і баг випливе в рантаймі.

    Споріднена конструкція — assertion-функція, що не повертає прапорець, а кидає виняток, якщо умова не виконана:

    function assertUser(value: unknown): asserts value is User {
      if (!isUser(value)) throw new Error("Not a User");
    }
    
    assertUser(payload);
    payload.email;   // після виклику payload вже звужено до User

    Це зручно у фікстурах і хелперах підготовки даних: одна перевірка на вході — і далі весь код працює з гарантованим типом без вкладених if. Важливе застереження нижче в «Типових помилках»: ручні type guard'и легко розсинхронити з реальним типом. Для валідації зовнішніх даних у бойовому коді частіше беруть схемні валідатори (zod, ajv), які генерують і перевірку, і тип з одного джерела.

    Утилітарні типи

    Утилітарні типи — це вбудовані генерики, що трансформують один тип в інший. Замість описувати руками «те саме, але всі поля опційні» TypeScript дає готові конструкції. Найуживаніші в тестовому коді:

    ТипЩо робитьТиповий кейс в AQA
    Partial<T>усі поля T — опційнібілдер/оверрайди фікстури: create(overrides: Partial<User>)
    Required<T>усі поля — обов'язковізняти опційність із конфіга після дефолтів
    Pick<T, K>лишити лише ключі Kвузький DTO: Pick<User, "id" | "email">
    Omit<T, K>прибрати ключі Kтіло на створення без серверного id: Omit<User, "id">
    Record<K, V>об'єкт із ключами K і значеннями Vмапа даних: Record<Role, User>
    ReturnType<F>тип, який повертає функція Fтип фікстури з фабрики без ручного опису

    Найчастіша пара в тестових даних — Partial і фабрика. Білдер приймає часткові оверрайди й зливає їх із дефолтами:

    const defaultUser: User = { id: 1, email: "a@test.io", role: "admin" };
    
    function buildUser(overrides: Partial<User> = {}): User {
      return { ...defaultUser, ...overrides };
    }
    
    buildUser({ role: "guest" });   // решта полів — з дефолтів

    Omit і Pick описують форми запитів і відповідей одну через одну, щоб не тримати три майже однакові типи: тіло на створення — це Omit<User, "id"> (сервер видасть id сам), коротка картка — Pick<User, "id" | "email">. Коли форма зміниться в базовому User, похідні типи оновляться автоматично — це і є головна цінність: одне джерело істини.

    ReturnType найкорисніший у зв'язці з typeof (див. нижче): ReturnType<typeof buildUser> дає тип User, не змушуючи його дублювати. Для async-фабрик є Awaited<ReturnType<typeof f>>, який розгортає Promise<User> до User. Ці ж утиліти стосуються асинхронних відповідей — деталі роботи з промісами в главі Асинхронність.

    keyof і typeof на рівні типів

    Дві операції, що працюють у просторі типів, а не значень, і разом дають будувати типи з наявних даних.

    keyof T повертає union літеральних типів усіх ключів об'єкта:

    type User = { id: number; email: string; role: string };
    type UserKey = keyof User;   // "id" | "email" | "role"

    Це дозволяє писати хелпери, що приймають лише реально наявні ключі. Класичний типобезпечний getField:

    function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    getField({ id: 1, email: "a@test.io" }, "email");  // тип string
    getField({ id: 1 }, "name");                        // помилка: немає ключа

    T[K] тут — індексований доступ (indexed access): «тип значення під ключем K». Для key: "id" повернеться number, для "email"string. Компілятор не дасть передати ключ, якого немає, і поверне точний тип поля, а не any.

    Оператор typeof у контексті типу (не плутати з рантайм-typeof зі звуження вище) бере тип із наявного значення:

    const config = { baseURL: "https://test.io", timeout: 30000 };
    type Config = typeof config;   // { baseURL: string; timeout: number }

    Зв'язка keyof typeof — стандартний спосіб отримати ключі об'єкта-константи, коли окремого типу нема:

    const roles = { admin: 1, guest: 2 } as const;
    type Role = keyof typeof roles;   // "admin" | "guest"

    Саме так типізують дані, що вже описані значенням: не дублюєш тип руками, а виводиш його з константи — і він не розсинхронізується.

    as і satisfies

    Два оператори, які часто плутають, бо обидва згадують поруч із типом. Різниця принципова.

    as — це твердження типу (type assertion): наказ компілятору «повір, тут саме цей тип», без жодної перевірки в рантаймі. TypeScript просто вимикає власний контроль:

    const el = document.querySelector(".btn") as HTMLButtonElement;
    const data = JSON.parse(raw) as User;   // ніхто не перевірив, що це справді User

    as не конвертує й не валідує — він лише мовчить. Якщо JSON.parse поверне не User, компілятор все одно повірить, а .email у рантаймі дасть undefined або впаде глибше. Тому as на зовнішніх даних — це розписка «я знаю краще», яка знімає захист рівно там, де він найпотрібніший. Легітимні застосування: звуження, яке компілятор не може вивести сам (DOM-елементи), і as const — особлива форма, що робить літерали максимально вузькими й readonly:

    const status = "passed" as const;         // тип "passed", не string
    const tags = ["smoke", "api"] as const;   // readonly ["smoke", "api"]

    satisfies (з TypeScript 4.9) розв'язує іншу задачу: перевірити, що значення відповідає типу, але не розширювати виведений тип до цього типу. Порівняй:

    type Routes = Record<string, string>;
    
    // анотація: тип змінної стає Record<string, string> — конкретні ключі губляться
    const a: Routes = { home: "/", login: "/login" };
    a.profile;   // компілятор мовчить (Record дозволяє будь-який ключ), у рантаймі undefined
    
    // satisfies: перевіряє відповідність, але лишає точний виведений тип
    const b = { home: "/", login: "/login" } satisfies Routes;
    b.profile;   // помилка компіляції: такого ключа немає
    b.home;      // тип "/", точний літерал

    Коротко: анотація (: Type) і as віддають тобі широкий тип; satisfies перевіряє форму, але зберігає вузький, реально виведений. Для конфігів, мап роутів і наборів фікстур satisfies — правильний вибір: ловить помилку в самому об'єкті й не «з'їдає» конкретику ключів і літералів.

    : Type (анотація)

    as Type

    satisfies Type

    Об'єкт-літерал

    Як зв'язати з типом?

    Перевірка є,
    тип розширено до Type

    Перевірки нема,
    компілятор вірить наосліп

    Перевірка є,
    вузький тип збережено

    : Type (анотація)

    as Type

    satisfies Type

    Об'єкт-літерал

    Як зв'язати з типом?

    Перевірка є,
    тип розширено до Type

    Перевірки нема,
    компілятор вірить наосліп

    Перевірка є,
    вузький тип збережено

    Типізація фікстур і API-відповідей

    Тут усе вищеописане сходиться в дві щоденні задачі AQA.

    Фікстури Playwright. Механізм test.extend — генерик: параметр типу описує форму фікстур, які додаєш. Оголосивши її, отримуєш автодоповнення полів прямо в тесті:

    import { test as base } from "@playwright/test";
    
    type Fixtures = { authUser: User; apiToken: string };
    
    export const test = base.extend<Fixtures>({
      authUser: async ({}, use) => {
        await use(buildUser({ role: "admin" }));
      },
      apiToken: async ({}, use) => {
        await use(await login());
      },
    });
    
    test("бачить дашборд", async ({ authUser, apiToken }) => {
      // authUser типізовано як User, apiToken — string
    });

    Замість описувати тип фікстури руками, часто виводять його з фабрики: ReturnType<typeof buildUser>. Тоді зміна фабрики автоматично протікає в тип фікстури.

    API-відповіді. response.json() у Playwright і у fetch повертає anyfetchPromise<any>). any тихо вимикає перевірки: body.usr.email скомпілюється й впаде лише в рантаймі. Дисципліна — приймати відповідь як unknown і звужувати type guard'ом або схемним валідатором:

    const raw: unknown = await response.json();
    assertUser(raw);          // кине зрозумілу помилку, якщо форма не та
    expect(raw.email).toBe("a@test.io");   // raw звужено до User

    Так помилка «сервер повернув не те» стає явною й локалізованою на межі, а не спливає через п'ять рядків як загадкове undefined. Для реальних проєктів межу зазвичай тримають zod/ajv: одна схема дає і рантайм-валідацію, і статичний тип (z.infer), тож вони не розходяться. Про те, як увімкнути строгі перевірки, що роблять any з відповідей видимим, — глава tsconfig і TypeScript у тестовому проєкті.

    Типові помилки

    • as User замість перевірки. Виглядає як типізація відповіді, а насправді — вимкнення перевірок: as нічого не валідує в рантаймі. Сервер змінив форму — компілятор мовчить, тест падає незрозуміло. Правильно: unknown + type guard або схема.
    • Type guard, що розсинхронився з типом. Виглядає як захист, а насправді — брехня компілятору: додали в User поле role, а isUser його не перевіряє — предикат далі каже true, і код читає неіснуюче поле як гарантоване. Ручні guard'и старіють; схемні валідатори від цього застраховані.
    • Partial<T> там, де потрібен Required чи навпаки. Виглядає як дрібниця, а насправді — дірка в перевірках: Partial<Config> дозволяє прокинути порожній об'єкт туди, де всі поля обов'язкові, і undefined розходиться по коду.
    • Анотація : Record<string, string> замість satisfies. Виглядає як типізація мапи, а насправді — втрата ключів: Record дозволяє будь-який рядковий ключ, тож звернення до неіснуючого роуту компілятор пропускає. satisfies ловить це на місці.
    • instanceof на JSON-об'єкті. Виглядає як перевірка форми, а насправді завжди false: розпарсений JSON не має прототипу твого класу. Для «сирих» об'єктів — in або кастомний guard, instanceof — лише для того, що створено через new.
    • Голий T без extends, а всередині — t.id. Виглядає як генерик, а насправді помилка компіляції або спокуса кинути any: без обмеження TypeScript не знає, що в T є .id. Додай T extends { id: number }.

    Підсумок

    • Генерик зберігає зв'язок «тип входу = тип виходу»; extends задає мінімальний контракт, який тип-аргумент мусить виконати, і відкриває доступ до його полів усередині.
    • Звуження — це аналіз потоку керування: typeof для примітивів, instanceof для класів, in і дискримінований union для об'єктів; після перевірки тип у гілці вужчий.
    • Кастомний type guard (x is T) і assertion-функція (asserts x is T) переносять звуження туди, куди вбудовані оператори не дістають, але їхню коректність гарантуєш ти, не компілятор.
    • Утилітарні типи (Partial/Required/Pick/Omit/Record/ReturnType) виводять похідні форми з одного базового типу, тож зміна джерела протікає в усі похідні автоматично.
    • as вимикає перевірку (небезпечно на зовнішніх даних), satisfies перевіряє форму й зберігає вузький тип; keyof typeof виводить тип із наявної константи без дублювання.

    Що питають на співбесіді

    • «Чим as відрізняється від satisfies Інтерв'юер перевіряє, чи розумієш, що as — це наказ без рантайм-ефекту (може збрехати), а satisfies — перевірка з тим, що вузький виведений тип зберігається. Сильна відповідь згадує небезпеку as на JSON.parse.
    • «Як безпечно типізувати відповідь API?» Дивляться, чи не скажеш «as User». Очікують: приймати як unknown, звузити type guard'ом або схемним валідатором (zod/ajv), бо as і any тихо вимикають захист саме на межі із зовнішнім світом.
    • «Напишіть type guard / поясніть x is T Перевіряють, що знаєш синтаксис предиката й розумієш: компілятор довіряє результату, а за правильність тіла відповідаєш ти.
    • «Навіщо extends у генериках?» Хочуть почути, що це обмеження (constraint), яке дає доступ до полів усередині й забороняє несумісні тип-аргументи, — а не успадкування класів.
    • «Що таке Partial/Omit і де ви їх застосовували?» Місток до практики: очікують приклад білдера фікстур (Partial<T> для оверрайдів) чи DTO на створення (Omit<T, "id">). Абстрактна відповідь без кейсу з тестового коду читається слабше.
    • «Що робить keyof typeof Дивляться, чи розрізняєш рантайм-typeof (звуження) і typeof на рівні типів (вивести тип із значення), і чи бачив зв'язку keyof typeof obj для ключів константи.

    Джерела