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, function | union примітивів |
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-відповідей.
Кастомні 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 тобі не заважає перевіряти руками (бо value — unknown), а на місці виклику довіряє результату й звужує тип. Відповідальність за коректність предиката — на тобі: якщо збрешеш у тілі 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 — правильний вибір: ловить помилку в самому об'єкті й не «з'їдає» конкретику ключів і літералів.
Типізація фікстур і 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 повертає any (у fetch — Promise<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для ключів константи.
Джерела
- TypeScript Handbook — Generics — генерики й обмеження
extends. - TypeScript Handbook — Narrowing —
typeof/in/instanceof, type guards іasserts. - TypeScript Handbook — Utility Types —
Partial,Pick,Omit,Record,ReturnTypeта інші. - TypeScript 4.9 Release Notes — the satisfies operator — семантика
satisfiesі відмінність від анотації таas. - Playwright — Test fixtures — типізація фікстур через
test.extend.
Що таке генерик і яку проблему він розвʼязує?
Генерик (generic) — це тип, параметризований іншим типом: він приймає тип-аргумент так само, як функція приймає значення-аргумент. Потрібен він тоді, коли один хелпер має працювати з різними формами даних, а жорсткий тип змусив би або дублювати функцію під кожну сутність, або зісковзнути на any й розгубити всі перевірки. Візьми функцію, що повертає перший елемент масиву: з підписом first<T>(items: T[]): T | undefined вона тримає зв'язок «що на вході — те й на виході», тож для масиву чисел віддасть number | undefined, а для рядків — string | undefined. Якби замість T стояло any[], результат став би any, і автодоповнення з контролем типів зникли б саме там, де мали захищати. У тестовому коді на генериках стоять типобезпечні білдери фікстур і хелпери на кшталт pick, що приймають будь-яку сутність.
Як TypeScript визначає, чим є T під час виклику генерика?
Здебільшого компілятор виводить (inference) тип-аргумент сам зі значень, які ти передав, — писати first<number>([1, 2]) руками майже ніколи не доводиться. Дав масив чисел — T став number, дав рядки — string. Явна вказівка тип-аргументу в кутових дужках потрібна лише там, де вивести його нема звідки: порожній контейнер або значення, з якого форму не видно. Додатково існує тип за замовчуванням — function make<T = string>() підставить string, якщо підстав вивести щось інше немає. Тобто порядок такий: спершу явний тип-аргумент, тоді виведення зі значень, а вже потім дефолт.
Навіщо в генериках extends і чим воно відрізняється від успадкування класів?
extends у генериках задає обмеження (constraint) — мінімальний контракт, якому тип-аргумент мусить відповідати, а не ієрархію «батько-нащадок». Голий T не дає жодних гарантій: усередині функції не можна читати ні .id, ні .length, бо T — будь-що. Запис T extends { id: number } каже компілятору «T — будь-який тип, сумісний із цією формою», і тоді всередині доступне item.id, а на виході зберігається повний тип елемента з усіма полями, не обрізаний до { id: number }. Плутанина з класовим extends тут головна пастка на співбесіді: це «підтип / сумісний із», а не наслідування реалізації. На цьому ж механізмі побудована половина утилітарних типів.
Що таке звуження типів (narrowing) і як компілятор його виконує?
Звуження — це коли на підставі перевірок у коді TypeScript у конкретній гілці бачить тип вужчий за оголошений. Union на кшталт string | number описує кілька форм одразу, але .toFixed() є лише в number, тож поки тип не звужено, компілятор не дасть викликати метод однієї з форм. Робить це аналіз потоку керування (control flow analysis): після if (typeof value === "number") у тілі if змінна вже точно number, а в гілці else — string. Тобто ти не переконуєш компілятор словами, а він сам читає твої перевірки й перераховує тип по ходу виконання. Практично це те, що дає безпечно розібрати відповідь мережі чи User | null, не хапаючись за as.
Які є способи звуження і коли кожен доречний?
Базовий набір: typeof x === "..." для union примітивів (string, number, boolean тощо), x instanceof C для класів і вбудованих обʼєктів (Error, Date), "key" in x для union обʼєктів різної форми, truthiness-перевірка if (x) щоб відсіяти null/undefined, порівняння x === "passed" для union літералів і Array.isArray(x) для випадку T | T[]. Вибір диктує те, що саме треба розрізнити: примітиви — typeof, класи — instanceof, обʼєкти-«сирці» без класу — in або кастомний guard. Окремо стоїть дискримінований union, де розрізняють за спільним полем-міткою. Знати межі кожного важливо: неправильний інструмент дає або помилку компіляції, або тихо неправильне звуження.
Чому typeof не годиться для розрізнення обʼєктів?
Бо для всього обʼєктного typeof повертає однакове "object": і typeof {}, і typeof [], і typeof null дають рядок, з якого форму не відновити (null взагалі історична аномалія). Тому union на кшталт User | Order через typeof розрізнити неможливо — обидва «object». Тут вмикається in (перевірка за наявним полем: "email" in x) або instanceof (перевірка за прототипом класу). Останній працює лише з тим, що створено через new і має відповідний прототип, тож годиться для Error чи власних класів, але не для розпарсеного JSON — у нього прототип звичайний, і payload instanceof User завжди false. Для «сирих» обʼєктів залишаються in і кастомний type guard.
Що таке дискримінований union і чому він зручний для API-відповідей?
Дискримінований union (discriminated union) — це union обʼєктів, у кожному з яких є спільне поле-мітка з літеральним типом, наприклад status: "ok" проти status: "error". Перевірка цієї мітки звужує весь обʼєкт: після if (res.status === "ok") компілятор знає, що є res.data, а в іншій гілці — що є res.message. Це найнадійніший спосіб типізувати «або успіх, або помилка» — саме та форма, у якій найчастіше приходять відповіді бекенду. Перевага перед перевіркою окремих полів у тому, що мітка одна й вона керує всією формою: не треба гадати, які поля сумісні між собою. У тестах це дає читати data чи message без as і без ризику звернутися до поля, якого в цій гілці немає.
Що таке кастомний type guard і як працює предикат x is T?
Коли вбудованих typeof/in/instanceof замало, логіку перевірки виносять у функцію-охоронець (type guard), яка замість boolean повертає особливий тип-предикат value is T. Це обіцянка компілятору: «якщо я повернула true, вважай значення типом T». Усередині ти перевіряєш руками (бо приймаєш unknown), а на місці виклику компілятор довіряє результату й звужує тип у гілці if. Приклад — isUser(value): value is User, що перевіряє наявність числового id і рядкового email. Ключове застереження: за коректність тіла відповідаєш ти, не компілятор — якщо предикат бреше, звуження теж бреше, і баг випливе в рантаймі. Саме тому для зовнішніх даних часто беруть схемні валідатори замість ручних guardʼів.
Чим assertion-функція (asserts x is T) відрізняється від звичайного type guard?
Type guard повертає прапорець, який ти сам перевіряєш у if; assertion-функція нічого не повертає, а кидає виняток, якщо умова не виконана. Її підпис — function assertUser(value: unknown): asserts value is User, і після її виклику решта коду вже працює зі звуженим типом без обгортання в if. Це зручно на межі підготовки даних: одна перевірка на вході — і далі гарантований тип по всьому тілу. Різниця в стилі керування: guard дає розгалуження (if (isUser(x)) {...} else {...}), assertion — «або далі з правильним типом, або падаємо тут-таки зі зрозумілою помилкою». В API-тестах assertion зручний тим, що перетворює «сервер повернув не те» на явний фейл на межі, а не на загадкове undefined через пʼять рядків.
Чим as відрізняється від satisfies?
as — це твердження типу (type assertion), наказ компілятору «повір, тут саме цей тип» без жодної перевірки в рантаймі; він лише вимикає власний контроль TypeScript і нічого не конвертує. satisfies (з TypeScript 4.9) — навпаки, перевіряє, що значення відповідає типу, але не розширює виведений тип до нього, тобто зберігає точну, вузьку форму з конкретними ключами й літералами. Порівняй мапу роутів: const a: Routes = {...} і const a = {...} as Routes віддають широкий Record<string, string>, тож звернення до неіснуючого ключа компілятор пропустить; а const b = {...} satisfies Routes зловить помилку в самому обʼєкті й лишить точні типи полів. Коротко: анотація і as дають широкий тип, satisfies перевіряє форму й тримає вузький. Сильна відповідь додає, що as небезпечний на результаті JSON.parse, бо може мовчки збрехати.
Чому as User на відповіді API — погана ідея?
Тому що as не валідує нічого в рантаймі — він лише глушить перевірки компілятора саме на межі із зовнішнім світом, де вони найпотрібніші. Написав const data = await res.json() as User — і компілятор повірить, що там User, навіть якщо сервер змінив форму: data.email спокійно скомпілюється, а в рантаймі дасть undefined або впаде глибше й незрозуміло. Виглядає як типізація відповіді, а фактично це розписка «я знаю краще», що знімає захист. Правильний підхід — прийняти відповідь як unknown і звузити її type guardʼом чи assertion-функцією, а в бойовому коді — схемним валідатором (zod, ajv), який дає і рантайм-перевірку, і статичний тип з одного джерела. Тоді помилка «прийшло не те» стає явною й локалізованою на вході.
Що робить as const і навіщо він потрібен?
as const — особлива форма твердження, яка робить літерали максимально вузькими й readonly: const status = "passed" as const дає тип "passed", а не string, а ["smoke", "api"] as const — readonly ["smoke", "api"], а не string[]. Без нього TypeScript «розширює» літерали до загального типу, і ти втрачаєш конкретику, потрібну для дискримінованих union чи ключів. Типовий кейс — константа-обʼєкт, з якої потім виводять тип: const roles = { admin: 1, guest: 2 } as const дозволяє написати keyof typeof roles і отримати "admin" | "guest". Тобто as const — це не «вимкнути перевірку», як звичайний as, а «зафіксувати найточніший можливий тип». Це один із небагатьох випадків, де as доречний і безпечний.
Що таке keyof і typeof на рівні типів, і що дає їх зв'язка?
keyof T повертає union літеральних типів усіх ключів обʼєкта: для { id: number; email: string } це "id" | "email". Оператор typeof у контексті типу (не плутати з рантайм-typeof зі звуження) бере тип із наявного значення: type Config = typeof config виведе форму обʼєкта-константи. Разом keyof typeof obj дає union ключів обʼєкта, для якого окремого типу не оголошено, — стандартний спосіб не дублювати тип руками, а вивести його з константи. На цьому будують типобезпечні хелпери: getField<T, K extends keyof T>(obj, key): T[K] не дасть передати неіснуючий ключ і поверне точний тип поля через індексований доступ T[K], а не any. Головна цінність — джерело істини одне: змінилося значення, автоматично оновився й тип.
Розкажи про Partial, Pick, Omit, Record, ReturnType — де застосовував у тестах?
Це вбудовані генерики, що виводять похідні типи з базового. Partial<T> робить усі поля опційними — основа білдера фікстур: buildUser(overrides: Partial<User> = {}) зливає часткові оверрайди з дефолтами. Pick<T, K> лишає тільки перелічені ключі (вузький DTO — Pick<User, "id" | "email">), Omit<T, K> навпаки прибирає їх (тіло на створення без серверного id — Omit<User, "id">). Record<K, V> описує мапу «ключ → значення» (наприклад Record<Role, User>), а ReturnType<F> дає тип, що повертає функція, — зручно взяти тип фікстури просто з фабрики: ReturnType<typeof buildUser>. Головна вигода — одне джерело істини: зміниш базовий User, і всі похідні форми оновляться самі. Для async-фабрик додають Awaited<ReturnType<typeof f>>, щоб розгорнути Promise<User> до User.
Що повертає response.json() і як безпечно типізувати відповідь API?
І в fetch, і в Playwright response.json() повертає any (у fetch — Promise<any>), а any тихо вимикає перевірки: body.usr.email з одруком спокійно скомпілюється й впаде лише в рантаймі. Дисципліна — не довіряти цьому any, а прийняти відповідь як unknown і звузити її явно: type guardʼом, assertion-функцією або схемним валідатором. Тоді помилка «сервер повернув не ту форму» стає видимою на межі, а не спливає загадковим undefined глибше в тесті. Категорично не варто підмінювати перевірку на as User — це той самий вимкнений захист, лише замаскований. Для реальних проєктів межу зазвичай тримають zod/ajv: одна схема дає і рантайм-валідацію, і статичний тип (z.infer), тож вони не розходяться між собою.
Як типізують фікстури в Playwright через test.extend?
test.extend — генерик: його тип-параметр описує форму фікстур, які ти додаєш, тож оголосивши base.extend<Fixtures>({...}), ти отримуєш автодоповнення полів прямо в тесті. Наприклад, з type Fixtures = { authUser: User; apiToken: string } у тілі тесту authUser типізовано як User, а apiToken — як string, і IDE підказує їхні поля замість мовчання. Замість описувати тип фікстури руками часто виводять його з фабрики через ReturnType<typeof buildUser> — тоді зміна фабрики автоматично протікає в тип фікстури, і вони не розсинхронюються. Це та сама ідея одного джерела істини, що й з утилітарними типами. Практична вигода: помилку в назві поля фікстури компілятор ловить ще до запуску тесту, а не в рантаймі як undefined.
Три кейси, у яких генерики, звуження й утилітарні типи вирішують, чи впаде тест зрозуміло, чи спливе загадковим undefined: типобезпечний білдер фікстур на Partial<T>, безпечний розбір відповіді API через unknown + assertion-функцію, і вибір між as та satisfies для набору тестових даних. Скрізь — що робити і чому саме так.
Кейс 1. Білдер фікстур: Partial<T> + виведення типу з фабрики
Тестам потрібен User, але кожен кейс хоче свій нюанс: тут role: "guest", там прострочений email. Описувати повний обʼєкт у кожному тесті — шум і дублювання; приймати «будь-який обʼєкт» — втратити перевірки. Правильна форма — фабрика з дефолтами, що бере часткові оверрайди через Partial<User>.
type User = { id: number; email: string; role: "admin" | "guest" };
const defaultUser: User = { id: 1, email: "a@test.io", role: "admin" };
function buildUser(overrides: Partial<User> = {}): User {
return { ...defaultUser, ...overrides };
}
const guest = buildUser({ role: "guest" }); // решта полів — з дефолтів
const typo = buildUser({ rol: "guest" }); // помилка компіляції: немає ключа rol
Далі тип фікстури не описуємо руками, а виводимо з фабрики — тоді зміна buildUser протікає в тип автоматично:
type BuiltUser = ReturnType<typeof buildUser>; // User, без ручного дублювання
// для async-фабрики розгортаємо Promise
async function buildUserAsync() {
return buildUser({ role: "admin" });
}
type BuiltAsync = Awaited<ReturnType<typeof buildUserAsync>>; // теж User
Що дивитися і чому:
Partial<User>, а неUser, у параметрі оверрайдів. Оверрайди — часткові за визначенням, тож повнийUserзмусив би передавати всі поля щоразу. Але межа тонка: якщо функція вимагає, щоб поле точно було,Partialтам — дірка, через якуundefinedрозійдеться по коду.rolзамістьroleкомпілятор ловить одразу. Це і є сенс типізованого білдера проти голого обʼєкта: одрук у ключі оверрайду червоніє на компіляції, а не стає мовчазнимundefinedу тесті.ReturnType<typeof buildUser>— одне джерело істини. Додаси вUserполе — фабрика й похідний тип оновляться разом. Продублюєш тип руками — рано чи пізно він розійдеться з фабрикою, і саме це видасть плутаний фейл.
Кейс 2. Відповідь API: unknown + assertion замість as User
response.json() у Playwright і fetch віддає any, а any тихо пропускає будь-яке звернення до полів. Спокуса — дописати as User і читати body.email. Проблема: as нічого не перевіряє, тож коли бекенд змінить форму, тест впаде не на межі, а десь глибше й незрозуміло. Дисципліна — прийняти як unknown і звузити явно.
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"
);
}
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) throw new Error("Відповідь не має форми User");
}
У самому тесті assertion ставить чіткий барʼєр на вході:
import { test, expect } from "@playwright/test";
test("GET /user повертає обʼєкт форми User", async ({ request }) => {
const res = await request.get("https://app.example.com/api/user/1");
expect(res.status()).toBe(200);
const raw: unknown = await res.json();
assertUser(raw); // кине зрозумілу помилку, якщо форма не та
expect(raw.email).toBe("a@test.io"); // тут raw вже звужено до User
});
Що дивитися і чому:
unknown, а неany, на вході.anyвимкнув би перевірки мовчки;unknownзмушує звузити тип, перш ніж читати поля, — компілятор не дасть звернутися доraw.emailбез guardʼа.assertUserперетворює «прийшло не те» на явний фейл. Помилка каже, що зламалася саме форма відповіді, і падає на межі. Без цього одрук сервераusrзамістьuserвиліз би через кілька рядків якundefined, і час пішов би на хибний слід.instanceofтут не спрацював би.res.json()дає звичайний обʼєкт без прототипу класуUser, тожraw instanceof Userзавждиfalse— розрізняти форму можна лише черезin/ перевірку полів, як уisUser.- У бойовому коді межу тримають zod/ajv. Ручний guard старіє: додаси в
Userполе, аisUserйого не перевірятиме. Схема дає рантайм-перевірку й тип (z.infer) з одного джерела, тож вони не розходяться.
Коли форм відповіді кілька, надійніше типізувати їх дискримінованим union і звужувати за міткою:
type ApiResult =
| { status: "ok"; data: User }
| { status: "error"; message: string };
function readResult(res: ApiResult): User | null {
if (res.status === "ok") return res.data; // звужено до успіху
console.warn(res.message); // звужено до помилки
return null;
}
Кейс 3. Набір тестових даних: as vs satisfies
Є мапа фікстур «роль → користувач». Хочеться і перевірки, що значення правильної форми, і збереження точних ключів, щоб звернення до неіснуючої ролі впало на компіляції. Порівняймо три способи звʼязати обʼєкт із типом:
| Спосіб | Перевірка форми | Тип змінної | Ключ, якого немає |
|---|---|---|---|
: Record<string, User> (анотація) | є | широкий Record | компілятор мовчить |
as Record<string, User> | немає | широкий Record | компілятор мовчить |
satisfies Record<string, User> | є | точний, вузький | помилка компіляції |
const users = {
admin: buildUser({ role: "admin" }),
guest: buildUser({ role: "guest" }),
} satisfies Record<string, User>;
users.admin; // ок, тип User
users.manager; // помилка компіляції: такого ключа немає
Що дивитися і чому:
- Анотація й
asрозширюють тип доRecord<string, User>. Тодіusers.managerкомпілятор пропустить, боRecordдозволяє будь-який рядковий ключ, — а в рантаймі цеundefinedі плутаний фейл у тесті, що звернувся до неіснуючої фікстури. satisfiesперевіряє форму й лишає вузький тип. Кожне значення звіряється зUser, але точні ключі (admin,guest) зберігаються, тож звернення доmanagerчервоніє одразу.asтут — найгірший варіант. Він не перевіряє навіть форму значень: якщо в обʼєкт закрадеться поле не зUser,asце проковтне. Для наборів даних, конфігів і мап роутів дефолт —satisfies, аasлишай для випадків, де компілятор об'єктивно не може вивести тип сам (наприклад, DOM-елемент).
Генерики й extends
- Розумію, що генерик — тип, параметризований типом: тримає зв'язок «тип входу = тип виходу» (
first<T>(items: T[]): T | undefined) і рятує від дублювання функцій чи скочування наany[], що цей зв'язок рве. - Знаю, що тип-аргумент здебільшого виводиться зі значень (inference), явно в кутових дужках його вказують лише коли вивести нема звідки, і що є дефолтний тип-параметр
<T = string>. - Розумію
extendsу генериках як обмеження (constraint) — мінімальний контракт, а не успадкування класів. - Можу пояснити, що
T extends { id: number }відкриває доступ до.idусередині й водночас зберігає повний тип елемента на виході.
Звуження типів (narrowing)
- Розумію, що звуження робить аналіз потоку керування: після перевірки тип у гілці вужчий за оголошений.
- Знаю набір інструментів:
typeof(примітиви),instanceof(класи),in(обʼєкти різної форми), truthiness, порівняння літералів,Array.isArray. - Можу пояснити, чому
typeofбезсилий на обʼєктах (усе —"object") і чомуinstanceofзавждиfalseна розпарсеному JSON — для «сирих» обʼєктів потрібніinабо кастомний guard. - Можу описати дискримінований union (спільне поле-мітка) і чому це найнадійніша типізація «успіх або помилка».
Type guards і assertion-функції
- Знаю синтаксис предиката
value is Tі що на місці виклику компілятор довіряє результату й звужує тип. - Пам'ятаю, що за коректність тіла guardʼа відповідаю я, не компілятор: збрешеш — звуження теж збреше в рантаймі, тому ручні guardʼи старіють, а zod/ajv тримають тип і перевірку з одного джерела.
- Розрізняю type guard (повертає прапорець для
if) і assertion-функціюasserts x is T(кидає виняток, звужує далі безif).
Утилітарні типи
- Знаю, що роблять
Partial,Required,Pick,Omit,Record,ReturnType; типовий кейс — білдер фікстур наPartial<T>і DTO на створення черезOmit<T, "id">. - Розумію головну цінність — одне джерело істини (зміна базового типу протікає в усі похідні), і знаю
Awaited<ReturnType<typeof f>>для розгортанняPromise<User>в async-фабриках.
keyof, typeof і індексований доступ
- Розрізняю рантайм-
typeof(звуження значень) іtypeofна рівні типів (вивести тип із значення). - Знаю, що
keyof Tдає union літеральних ключів,keyof typeof obj— ключі константи без окремого типу, аT[K]— тип значення під ключем (основа типобезпечногоgetField).
as vs satisfies
- Розрізняю
as(наказ без рантайм-перевірки, широкий тип, може збрехати) іsatisfies(перевірка форми зі збереженням вузького типу — точні ключі й літерали). - Знаю
as constяк безпечну форму: фіксує найвужчийreadonly-тип для дискримінованих union і ключів. - Можу пояснити, чому
as UserнаJSON.parse/response.json()знімає захист саме там, де він найпотрібніший.
Типізація фікстур і API-відповідей
- Знаю, що
test.extend<Fixtures>— генерик, і як він дає автодоповнення полів фікстур у тесті. - Пам'ятаю, що
response.json()віддаєany— приймаю якunknownі звужую guardʼом/схемою, а тип фікстури виводжу з фабрики (ReturnType<typeof buildUser>), не дублюю руками.
Що зберігає генерик first<T>(items: T[]): T | undefined, чого не дає варіант із any[]?
Питання
Генерик (generic) — що це й навіщо?