QA Interview Hub · База знань · Тести

TypeScript

Просунуті типи: keyof, typeof, mapped, conditional, template literal

keyof, typeof і indexed access

keyof T дає union ключів типу. typeof value у позиції типу бере тип зі значення. T[K] - тип поля за ключем. Разом вони дозволяють виводити типи з одного джерела правди.

const ROUTES = {
  login: '/login',
  accounts: '/accounts',
  transfers: '/transfers/new',
} as const;

type RouteName = keyof typeof ROUTES;            // 'login' | 'accounts' | 'transfers'
type RoutePath = (typeof ROUTES)[RouteName];      // '/login' | '/accounts' | '/transfers/new'

async function goTo(page: Page, name: RouteName) {
  await page.goto(ROUTES[name]);
}

Тепер goTo(page, 'acounts') не скомпілюється, а новий маршрут автоматично доступний.

Indexed access працює і з масивами: (typeof CURRENCIES)[number] дає union елементів кортежу. І з вкладеними типами: Account['owner']['address'].

Різниця між двома typeof: у виразі JS typeof x === 'string' - це рантайм-оператор, який повертає рядок. У позиції типу type T = typeof x - це оператор TS, який бере статичний тип змінної. Синтаксис однаковий, сенс різний.

Корисний патерн для тестових даних:

const testUsers = {
  admin: { email: 'admin@test.io', role: 'admin' },
  operator: { email: 'op@test.io', role: 'operator' },
} as const;

type TestUserKey = keyof typeof testUsers; // 'admin' | 'operator'
type Role = (typeof testUsers)[TestUserKey]['role'];

Фікстура або хелпер приймає TestUserKey, і опечатки в ролях зникають.

На співбесіді: keyof typeof CONFIG дає union ключів константного об'єкта, а T[K] - тип значення. Я так типізую маршрути, ролі і тестових користувачів, щоб був один об'єкт як джерело правди і жодних опечаток у рядках.

Mapped types і conditional types

Mapped type проходить по ключах і будує новий тип. Саме так реалізовані Partial, Readonly, Record.

type Nullable<T> = { [K in keyof T]: T[K] | null };

// модифікатори: -? прибирає optional, -readonly прибирає readonly
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

З TS 4.1 можна перейменовувати ключі через as:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => Promise<T[K]>;
};

interface Balance { available: number; blocked: number }
type BalanceApi = Getters<Balance>;
// { getAvailable: () => Promise<number>; getBlocked: () => Promise<number> }

Conditional type - це "if" на рівні типів: T extends U ? X : Y. Разом з infer він дозволяє витягти частину типу.

type ItemOf<T> = T extends readonly (infer I)[] ? I : never;
type Tx = ItemOf<Transaction[]>; // Transaction

type UnwrapResult<T> = T extends { status: 'success'; data: infer D } ? D : never;

Важлива властивість - дистрибутивність. Якщо T - "голий" параметр типу і в нього передали union, умова застосовується до кожного члена окремо. Так працює Exclude<T, U> = T extends U ? never : T. Щоб вимкнути дистрибутивність, параметр загортають у кортеж: [T] extends [U].

Чесно про практику: у тестах я пишу такі типи рідко. Вони потрібні в шарі фреймворку - типізований API-клієнт, генератори фабрик. Якщо тип складно прочитати колезі, краще спростити.

На співбесіді: Mapped type проходить по ключах keyof T і будує новий тип, так влаштовані Partial і Readonly. Conditional type - це умова на рівні типів, а з infer можна витягти, наприклад, тип елемента масиву. У тестах я використовую це помірно, переважно в бібліотечному шарі.

Template literal types

Template literal types будують рядкові типи за шаблоном, як template strings, тільки на рівні типів. При підстановці union виходить декартовий добуток.

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Version = 'v1' | 'v2';
type ApiPath = `/api/${Version}/${string}`;

type RouteKey = `${Method} ${ApiPath}`;
const key: RouteKey = 'POST /api/v1/transfers'; // ок
// const bad: RouteKey = 'POST /transfers';     // помилка

${string} означає будь-який рядок у цій позиції, ${number} - рядок, що виглядає як число.

Вбудовані утиліти для рядкових типів: Uppercase, Lowercase, Capitalize, Uncapitalize.

Практичні приклади в автоматизації:

  • Тестові ідентифікатори для хелпера над getByTestId, щоб дотримуватись конвенції іменування (тип TestId нижче).
  • Ключі подій або метрик (тип TransferEvent нижче).
  • Формат даних (тип IsoDate нижче) - грубе обмеження, яке ловить очевидні помилки, але не валідує дату повністю. Рядок '1-2-3' теж пройде.
type TestId = `${'btn' | 'input' | 'row'}-${string}`;
type TransferEvent = `transfer.${'created' | 'completed' | 'failed'}`;
type IsoDate = `${number}-${number}-${number}`;
type Env = 'dev' | 'stage';
type BaseUrl = `https://${Env}.bank.example`;

function urlFor(env: Env): BaseUrl {
  return `https://${env}.bank.example`;
}

Обмеження: template literal types перевіряють лише літерали, відомі на етапі компіляції. Рядок з .env або з відповіді API має тип string, і його все одно треба валідувати в рантаймі.

На співбесіді: Template literal types задають шаблон рядка на рівні типів, наприклад метод плюс шлях, що починається з /api/. Я використовую їх для конвенцій - ключі маршрутів, test id, URL середовищ - але розумію, що це тільки компіляція, а дані ззовні валідую окремо.