Питання на співбесіді з TypeScript
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
У JSON немає типу «дата». Laravel серіалізує дати моделей як рядки ISO 8601 ("2026-10-04T07:00:00.000000Z"), і JSON.parse повертає саме рядок. Тип Date в інтерфейсі нічого не перетворює - це лише твердження розробника.
interface Order {
id: number;
createdAt: Date; // неправда: під час виконання тут рядок
}
const order: Order = await response.json();
order.createdAt.getFullYear(); // TypeError: getFullYear is not a function
TypeScript помилки не покаже - відповідь response.json() має тип any.
Варіант 1 - чесний тип: описати те, що справді приходить, і перетворювати там, де потрібно:
interface Order {
id: number;
createdAt: string; // ISO 8601
}
const created = new Date(order.createdAt);
Варіант 2 - перетворення на межі через схему:
import * as z from 'zod';
const OrderSchema = z.object({
id: z.number(),
createdAt: z.iso.datetime({ offset: true }).transform((value) => new Date(value)),
});
type Order = z.infer<typeof OrderSchema>; // createdAt: Date - тепер це правда
const order = OrderSchema.parse(await response.json());
Рядок перевіряється на формат і перетворюється на Date; далі весь код працює з датою.
Нюанси з датами:
- дата без часу (
"2026-10-04") уnew Date()розбирається як північ UTC - у Києві це ще 4 жовтня, а в Нью-Йорку вже 3-тє. Для дат без часу (день народження, дата події) краще лишати рядок або використовуватиTemporal.PlainDate; - дата з поясом (
...Zчи+03:00) - однозначна мить, її безпечно перетворювати; - у зворотному напрямку
JSON.stringify(new Date())дає рядок ISO в UTC - Laravel його правильно розбере.
Те саме стосується інших типів, яких немає в JSON: BigInt (великі id - краще рядками), Map/Set, undefined (зникає), гроші (decimal з Laravel часто приходить рядком "125.50" - і це правильно, щоб не втратити точність).
Загальне правило: тип даних з API описує формат JSON, а не бажану модель. Перетворення - явний крок у API-клієнті.
satisfies перевіряє, що значення відповідає типу, але не замінює виведений тип значення на цей тип.
Проблема з анотацією:
type Route = { path: string; auth?: boolean };
const routes: Record<string, Route> = {
home: { path: '/' },
admin: { path: '/admin', auth: true },
};
routes.admin.path; // ok
routes.nope.path; // теж компілюється - тип каже «будь-який рядок-ключ»
Анотація Record<string, Route> «стерла» знання про конкретні ключі: TypeScript більше не знає, що є лише home і admin.
З satisfies:
const routes = {
home: { path: '/' },
admin: { path: '/admin', auth: true },
} satisfies Record<string, Route>;
routes.admin.auth; // ok
routes.nope; // помилка: такого ключа немає
Об'єкт перевірено на відповідність Route (друкарська помилка в pth чи auth: 'yes' дасть помилку), а тип лишився точним - з конкретними ключами й значеннями.
Де це корисно:
- конфігурації й мапи: маршрути, переклади, налаштування таблиць, словники статусів;
- разом з
as const- точні літеральні типи плюс перевірка форми:
const statusColors = {
paid: 'green',
pending: 'amber',
} as const satisfies Record<OrderStatus, string>;
Якщо в OrderStatus з'явиться новий статус, TypeScript вкаже, що мапа неповна.
Порівняння трьох способів:
| Запис | Перевірка | Тип значення |
|---|---|---|
const x: T = ... |
так | T (широкий) |
const x = ... as T |
майже ні | T |
const x = ... satisfies T |
так | точний виведений |
as тут - найгірший варіант: приведення типу пропускає помилки, які satisfies і анотація зловили б.
as const каже TypeScript вивести найвужчий тип і зробити все лише для читання:
const roles = ['admin', 'editor', 'viewer']; // string[]
const roles2 = ['admin', 'editor', 'viewer'] as const; // readonly ['admin', 'editor', 'viewer']
type Role = (typeof roles2)[number]; // 'admin' | 'editor' | 'viewer'
roles2.push('guest'); // помилка: масив лише для читання
Головний прийом - одне джерело правди для значень і типу. Список значень потрібен і під час виконання (випадний список, валідація), і як тип. З as const тип виводиться зі значень, і вони не розходяться:
const ORDER_STATUSES = ['new', 'paid', 'shipped'] as const;
type OrderStatus = (typeof ORDER_STATUSES)[number];
function isOrderStatus(value: string): value is OrderStatus {
return (ORDER_STATUSES as readonly string[]).includes(value);
}
Це часто краща альтернатива enum: звичайний JavaScript-масив без особливого синтаксису.
Об'єкти:
const config = { api: { timeout: 5000 } } as const;
config.api.timeout = 10; // помилка - readonly на всіх рівнях
readonly у типах - для параметрів і полів, які функція не повинна змінювати:
function total(items: readonly CartItem[]): number {
items.sort(); // помилка: sort змінює масив
return items.reduce((sum, i) => sum + i.price, 0);
}
interface User {
readonly id: number;
name: string;
}
ReadonlyArray<T> / readonly T[] прибирають з типу методи, що змінюють масив (push, sort, splice).
Що варто знати:
- це лише перевірка компілятора. Під час виконання об'єкт звичайний - змінити його можна через
as anyчи з JavaScript-коду. Для справжньої незмінності -Object.freeze; readonlyповерхневий у звичайних типах:readonly items: Item[]забороняє замінити масив, але не змінити його вміст. Для вкладених структур -readonlyна кожному рівні чи утиліта на кшталтDeepReadonly;- масив
readonly string[]не можна передати туди, де очікуютьstring[]- функції, що не змінюють масив, варто оголошувати зreadonlyв параметрі.
Постфіксний ! каже компілятору: «це значення точно не null і не undefined». Компілятор вірить на слово й прибирає null | undefined з типу.
const input = document.querySelector('#email')!; // HTMLElement замість HTMLElement | null
const price = prices.get('coffee')!; // number замість number | undefined
! нічого не перевіряє під час виконання. Якщо значення все ж null, помилка виникне пізніше й далеко від причини: Cannot read properties of null. TypeScript саме для того й попереджав.
Коли ! доречний:
- значення гарантовано існує з причин, яких компілятор не бачить, і ця гарантія очевидна поруч у коді;
- ініціалізація, яку TypeScript не відстежує (поле класу, яке заповнює фреймворк), - хоча для полів краще
field!: Typeу оголошенні з коментарем, ніж!при кожному використанні.
Краще альтернативи в більшості випадків:
- явна перевірка з помилкою - падіння одразу з зрозумілим текстом:
const input = document.querySelector<HTMLInputElement>('#email');
if (!input) throw new Error('Поле #email не знайдено');
input.value; // тут уже HTMLInputElement
- функція-помічник:
function assertDefined<T>(value: T, message: string): asserts value is NonNullable<T> {
if (value == null) throw new Error(message);
}
- опціональний ланцюжок і значення за замовчуванням, якщо відсутність нормальна:
prices.get('coffee') ?? 0; - переписати код так, щоб тип був точним: зберігати знайдений елемент у змінній після перевірки, а не шукати двічі.
Типові місця, де ! приховує баги:
map.get(key)!- ключа може не бути;array.find(...)!- елемента може не знайтися;useRef<HTMLDivElement>(null).current!в ефекті, що може виконатися до монтування;process.env.API_KEY!- змінну оточення можуть не задати.
Лінтер (@typescript-eslint/no-non-null-assertion) змушує обґрунтовувати кожен ! - корисне правило для команди.
Важливо: strict у TypeScript 6+ увімкнено за замовчуванням, тож перевірки на null діють навіть без явного "strict": true - і ! стає помітнішою «дірою» в цих перевірках.
value as T - твердження типу: розробник каже компілятору «вважай це значення типом T». Нічого не перетворюється й не перевіряється під час виконання.
const user = (await response.json()) as User;
user.email.toLowerCase(); // впаде, якщо API повернув { error: '...' }
Компілятор довіряє, а реальні дані можуть бути іншими. Помилка переноситься з місця, де дані прийшли, у випадкове місце далі в коді.
Що as дозволяє, а що ні:
- звужувати й розширювати в межах сумісних типів (
unknown→User,HTMLElement→HTMLInputElement); - приведення між несумісними типами (
'текст' as number) - помилка компіляції. Але подвійнеas unknown as Tобходить і це - верна ознака, що щось не так.
Альтернативи:
1. Перевірка під час виконання для зовнішніх даних (API, localStorage, форми):
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), email: z.email() });
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(await response.json()); // кидає помилку, якщо дані не ті
2. Користувацький type guard:
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'email' in value;
}
3. Звуження вбудованими перевірками: instanceof, typeof, in, перевірка поля-дискримінатора.
4. Типізоване API замість приведення: document.querySelector<HTMLInputElement>('input[name=email]') замість as HTMLInputElement; генерік у useState<User | null>(null).
5. satisfies для об'єктів-літералів - перевірка форми без втрати точного типу.
Коли as допустимий:
as const- не приведення, а звуження до літеральних типів;- тести й моки (
{} as Partial<Service> as Service) - з розумінням ризику; - місця, де ви знаєте більше за компілятор і це очевидно з контексту (наприклад, після власної перевірки, яку TypeScript не розпізнав).
Правило лінтера @typescript-eslint/consistent-type-assertions дає змогу заборонити as для об'єктних літералів і змусити використовувати анотації чи satisfies.
TypeScript перевіряє типи структурно: об'єкт підходить до типу, якщо має всі потрібні властивості потрібних типів. Зайві властивості цьому не заважають.
type Point = { x: number; y: number };
const point3d = { x: 1, y: 2, z: 3 };
const p: Point = point3d; // ок - є x і y, z просто ігнорується
Але для «свіжого» об'єкта-літерала діє додаткова перевірка зайвих властивостей:
const p: Point = { x: 1, y: 2, z: 3 };
// помилка: Object literal may only specify known properties, and 'z' does not exist in type 'Point'
Чому так. Якщо ви пишете літерал прямо там, де очікується конкретний тип, зайва властивість майже напевно - друкарська помилка або непорозуміння:
createUser({ name: 'Оля', emial: 'olia@example.com' }); // emial замість email - зловлено
Але якщо об'єкт уже існує й має ширшу форму, передати його туди, де потрібна частина полів, - нормальна практика структурної типізації.
Де перевірка зайвих властивостей не спрацьовує:
- об'єкт спершу присвоєно змінній, а потім передано;
- результат функції, розгортання (
{ ...defaults, extra: 1 }перевіряється, а от{ ...obj }, деobjмає зайве, - ні); - тип має індексну сигнатуру (
[key: string]: unknown) - тоді будь-які ключі допустимі; - приведення через
as.
Пастка з опціональними полями:
type Options = { timeout?: number; retries?: number };
const userOptions = { timeout: 500, retires: 3 }; // друкарська помилка в retries
const options: Options = userOptions; // жодної помилки! усі поля опціональні
Тип, у якого всі поля необов'язкові («слабкий тип»), TypeScript частково захищає: якщо в об'єкта немає жодного спільного поля з типом ({ timout: 500 }), буде помилка. Але одна правильна властивість поруч з друкарською помилкою - і перевірка мовчить, а retries тихо лишається не заданим.
Що робити: передавати літерали напряму в місця з типом, використовувати satisfies для об'єктів-конфігурацій, а для зовнішніх даних - валідацію під час виконання (Zod), яка може відкидати невідомі ключі (.strict()).
Generics - параметри типів: функція, клас чи тип працює з різними типами, зберігаючи зв'язок між ними. Без generics довелося б вибирати між дублюванням коду й any.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(['a', 'b']); // string | undefined
TypeScript сам вивів T з аргументу - писати first<number>(...) не потрібно.
Обмеження extends - вимога до параметра типу:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('abc', 'de'); // працює з рядками
longest([1, 2], [3]); // і з масивами
longest(10, 20); // помилка: у number немає length
keyof разом з generics - типобезпечний доступ до полів:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
pluck(users, 'email'); // string[]
pluck(users, 'emial'); // помилка компіляції: опечатка видна одразу
Де generics щодня: Array<T>, Promise<T>, Map<K, V>, хуки (useState<User | null>(null)), типізовані API-клієнти (get<User>('/users/1')).
Пастка: generic, який використовується лише раз (function log<T>(x: T): void), нічого не дає - тут достатньо unknown. Параметр типу має сенс, коли він пов'язує кілька місць: аргументи між собою або аргумент з результатом.
Звуження (narrowing) - TypeScript аналізує перевірки в коді й усередині гілки вважає тип вужчим, ніж оголошений.
function format(value: string | number | null) {
if (value === null) return '-';
if (typeof value === 'number') {
return value.toFixed(2); // тут value: number
}
return value.trim(); // а тут value: string
}
Що звужує тип:
typeof x === 'string',x instanceof Date,Array.isArray(x);- перевірки на
null/undefinedі на істинність; 'field' in obj- чи є властивість;- порівняння з літералом (
status === 'paid') - основа discriminated unions; - ранній
returnчиthrow: після них тип уже звужено до кінця функції.
Власний type guard - функція з типом повернення value is T:
interface Cat { meow(): void }
interface Dog { bark(): void }
function isCat(animal: Cat | Dog): animal is Cat {
return 'meow' in animal;
}
if (isCat(pet)) pet.meow();
Assertion function - кидає виняток, якщо умова не виконана, і звужує тип після виклику:
function assertDefined<T>(value: T): asserts value is NonNullable<T> {
if (value == null) throw new Error('Значення відсутнє');
}
Пастка: type guard - це обіцянка, яку компілятор не перевіряє. Якщо isCat повертає true для собаки, TypeScript повірить. Тому логіка в guard має бути простою й надійною, а для даних ззовні краще схема валідації.
Кортеж - масив фіксованої структури: відомо, скільки елементів і якого типу кожен.
type Point = [number, number];
type Entry = [key: string, value: number]; // іменовані елементи
const p: Point = [10, 20];
const [x, y] = p;
Імена елементів (key, value) нічого не змінюють у поведінці - вони лише для читабельності й підказок редактора.
Де кортежі зустрічаються:
- повернення кількох значень:
useStateв React повертає[value, setValue]- кортеж, тому деструктуризація дає правильні типи кожному елементу; Object.entries, пари ключ-значення;- параметри функцій як кортеж:
Parameters<typeof fn>- кортеж типів аргументів.
Необов'язкові елементи - лише в кінці:
type Range = [start: number, end?: number];
const r1: Range = [1];
const r2: Range = [1, 5];
Залишкові (rest) елементи - змінна довжина:
type Command = [name: string, ...args: string[]];
type WithFlag = [...items: number[], flag: boolean]; // rest може бути й не в кінці
Варіадичні кортежі - комбінування типів кортежів у узагальненнях:
function concat<A extends unknown[], B extends unknown[]>(a: [...A], b: [...B]): [...A, ...B] {
return [...a, ...b];
}
const result = concat([1, 'a'], [true]); // [number, string, boolean]
Так типізуються функції-обгортки, що додають аргументи на початок чи в кінець (bind, middleware, каррування).
Пастки:
- виведення типу масиву - не кортеж:
const pair = [1, 'a']має тип(string | number)[]. Для кортежу - анотація абоas const(тодіreadonly [1, 'a']); - кортеж можна змінити через методи масиву:
point.push(30)компілюється для звичайного кортежу.readonly [number, number]це забороняє; - довгі кортежі погано читаються: якщо елементів більше 2-3, краще об'єкт з іменованими полями -
{ lat, lng }замість[number, number].
Обидва способи складають тип з кількох частин:
interface HasId { id: number }
interface HasTimestamps { createdAt: string; updatedAt: string }
// успадкування інтерфейсів
interface Post extends HasId, HasTimestamps {
title: string;
}
// перетин типів
type Comment = HasId & HasTimestamps & { body: string };
Для простих випадків результат однаковий. Різниця - у конфліктах і в тому, як компілятор з ними працює.
1. Конфлікт властивостей.
interface A { status: string }
interface B extends A { status: number } // помилка одразу: number не сумісний з string
type C = { status: string } & { status: number }; // помилки немає...
// ...але C['status'] - це never: жодне значення не підійде, і об'єкт типу C не створити
extends повідомляє про несумісність в оголошенні. Перетин мовчки дає never, і помилка з'являється далеко - там, де намагаються створити об'єкт.
2. Продуктивність перевірки типів. Інтерфейси кешуються як іменовані типи; компілятор порівнює їх за назвою. Великі перетини перераховуються щоразу. Документація TypeScript з продуктивності радить у великих кодових базах складати об'єктні типи через interface ... extends, а не через довгі ланцюжки &.
3. Що можна скласти. extends в інтерфейсі - лише з об'єктними типами (й класами). Перетин працює з будь-якими типами, включно з об'єднаннями й узагальненнями:
type WithMeta<T> = T & { meta: { requestId: string } };
type Branded = string & { readonly __brand: 'UserId' };
Перетин з об'єднаннями розподіляється: (A | B) & C - це (A & C) | (B & C).
Перетин примітивів - string & number - дає never: значення, що водночас рядок і число, не існує.
Практичні правила:
- об'єктні сутності й їх розширення -
interface ... extends: краща діагностика й швидкість; - узагальнені «домішки» (
T & { ... }), брендовані типи, композиція типів з утиліт - перетин; - якщо перетин дає несподіваний
never, - майже завжди конфлікт однойменних властивостей.
void - функція нічого корисного не повертає; результат не слід використовувати:
function log(message: string): void {
console.log(message);
}
Особливість: тип функції з void, наприклад () => void для колбеку, дозволяє передати функцію, що щось повертає. Результат просто ігнорується:
const ids: number[] = [];
[1, 2].forEach((n) => ids.push(n)); // push повертає number, але forEach очікує () => void - ок
Це навмисно: інакше багато звичайних колбеків довелося б обгортати.
undefined як тип повернення - функція явно повертає undefined, і результат можна використовувати як значення. З TS 5.1 функцію з типом undefined можна не закінчувати return.
never - функція ніколи не повертає керування:
function fail(message: string): never {
throw new Error(message);
}
function loop(): never {
while (true) {}
}
Після виклику такої функції код недосяжний, і TypeScript це враховує при звуженні:
const user = findUser(id) ?? fail('Користувача не знайдено'); // user: User, без undefined
never також - порожній тип: значення такого типу не існує. Тому його використовують для перевірки вичерпності (const _exhaustive: never = value) і він зникає з об'єднань: string | never - це string.
unknown - функція повертає щось, але тип невідомий: викликач має перевірити перед використанням. Правильний тип для JSON.parse, результатів сторонніх бібліотек без типів, catch (error).
Підсумок:
| Тип | Значення |
|---|---|
void |
«результат не використовувати» |
undefined |
«повертає саме undefined» |
never |
«не повертає взагалі» (виняток, нескінченний цикл, вихід з процесу) |
unknown |
«повертає щось, перевір перед використанням» |
Пастка з асинхронністю: async функція, що нічого не повертає, має тип Promise<void>, а не void. Передача її туди, де очікується () => void (обробник події), дозволена, але відхилений проміс ніхто не обробить - лінтер no-misused-promises це помічає.
Умовний тип вибирає один з двох типів залежно від перевірки - тернарний оператор на рівні типів:
T extends U ? X : Y
«Якщо T можна присвоїти U - тип X, інакше Y».
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
Практичні приклади:
1. Тип результату залежно від аргументу:
type ApiResult<T extends 'one' | 'many'> = T extends 'one' ? User : User[];
declare function fetchUsers<T extends 'one' | 'many'>(mode: T): Promise<ApiResult<T>>;
const one = await fetchUsers('one'); // User
const many = await fetchUsers('many'); // User[]
2. Фільтрація об'єднань - так влаштовані вбудовані Exclude і Extract:
type Exclude<T, U> = T extends U ? never : T;
type Events = 'click' | 'focus' | 'keydown' | 'keyup';
type KeyEvents = Extract<Events, `key${string}`>; // 'keydown' | 'keyup'
type Other = Exclude<Events, `key${string}`>; // 'click' | 'focus'
never у гілці «викидає» член об'єднання.
3. NonNullable<T>, ReturnType<T>, Awaited<T> - теж умовні типи (останні два - з infer).
Особливість - розподіл по об'єднаннях: якщо перевіряється «голий» параметр типу, а передано об'єднання, умова застосовується до кожного члена окремо:
type ToArray<T> = T extends unknown ? T[] : never;
type R = ToArray<string | number>; // string[] | number[], а не (string | number)[]
Обмеження й поради:
- читабельність: вкладені умовні типи на кілька рівнів важко читати й підтримувати. Розбивайте на іменовані проміжні типи;
- у реалізації функції TypeScript не може звузити умовний тип повернення за аргументом - доводиться використовувати перевантаження функції або явне приведення в реалізації;
- глибина рекурсії обмежена: для рекурсивних умовних типів компілятор зупиняється з помилкою «Type instantiation is excessively deep».
Коли умовні типи доречні: бібліотеки й утиліти, типи API-клієнтів, похідні типи. У прикладному коді частіше вистачає об'єднань, перевантажень і узагальнень без умов.
infer оголошує змінну типу всередині умови умовного типу: «якщо T має таку форму, дістань звідти ось цю частину».
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<readonly [1, 'a']>; // 1 | 'a'
type C = ElementOf<number>; // never - не масив
Так влаштовані вбудовані утиліти:
type ReturnType<T> = T extends (...args: any) => infer R ? R : any;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type Awaited<T> = /* рекурсивно розгортає Promise і thenable */;
Практичні приклади:
// тип даних з функції, що повертає проміс
type Data<T> = T extends (...args: any[]) => Promise<infer D> ? D : never;
type UserData = Data<typeof fetchUser>;
// перший аргумент функції
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never;
// тип props React-компонента
type PropsOf<C> = C extends (props: infer P) => any ? P : never;
infer з шаблонними рядками - розбір рядкових типів:
type RouteParams<Path extends string> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | RouteParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type P = RouteParams<'/teams/:teamId/users/:userId'>; // 'teamId' | 'userId'
Так роутери (React Router, TanStack Router) типізують параметри маршрутів з рядка шляху.
infer з обмеженням (TS 4.7+) - дістати лише якщо підходить під тип:
type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
Що варто пам'ятати:
inferпрацює лише в частиніextendsумовного типу;- якщо структура не збіглася, спрацьовує гілка «інакше» - зазвичай
never, і про це легко забути: тип мовчки стаєnever, а не помилкою; - кілька кандидатів для однієї змінної (
infer Uу кількох позиціях) дають об'єднання чи перетин залежно від позиції (коваріантна чи контраваріантна).
Перевага: не потрібно експортувати допоміжні типи з бібліотек - їх можна витягти з типів функцій і компонентів.
Коли умовний тип перевіряє «голий» параметр типу (T extends ..., а не T[] чи [T]), а в T передано об'єднання, умова застосовується до кожного члена окремо, і результати об'єднуються:
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]
Навіщо так зроблено: на цьому побудовані фільтри об'єднань.
type Exclude<T, U> = T extends U ? never : T;
type R = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
Кожен член перевіряється окремо, never для відкинутих - зникає з об'єднання.
Коли розподіл заважає - потрібно перевірити об'єднання цілком:
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayAll<string | number>; // (string | number)[]
Загортання в кортеж ([T]) робить параметр «не голим» - розподілу немає.
Найвідоміша пастка - never:
type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>; // never, а не true!
never - порожнє об'єднання. Розподіл по порожньому об'єднанню дає порожній результат, тобто never. Правильна перевірка:
type IsNever<T> = [T] extends [never] ? true : false;
type Y = IsNever<never>; // true
Ще приклади, де важливо розуміти розподіл:
boolean- цеtrue | false, тожT extends true ? 'yes' : 'no'дляbooleanдасть'yes' | 'no';- перевірка, чи тип є об'єднанням - на основі порівняння розподіленого й нерозподіленого результатів;
keyofоб'єднання - лише спільні ключі, а розподільнийT extends unknown ? keyof T : never- усі ключі всіх членів.
Правило: розподіл є лише тоді, коли параметр типу стоїть зліва від extends сам по собі і в нього підставлено об'єднання. T[] extends ..., [T] extends ..., Promise<T> extends ... - не розподіляються.
Mapped type проходить по ключах і будує новий тип: { [K in keyof T]: ... }. Крім типу значень, можна змінювати модифікатори й самі ключі.
Модифікатори readonly і ? - додати (+, можна опускати) чи прибрати (-):
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] }; // усі поля обов'язкові
type Locked = { readonly id: number; name?: string };
type M = Mutable<Locked>; // { id: number; name?: string }
type C = Concrete<Locked>; // { readonly id: number; name: string }
Вбудовані Partial, Required, Readonly - саме такі типи.
Перейменування ключів через as (TS 4.1+):
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }
string & K - бо ключі можуть бути number | symbol, а Capitalize працює лише з рядками.
Фільтрація ключів - якщо as дає never, ключ зникає:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type S = OnlyStrings<{ id: number; name: string; email: string }>;
// { name: string; email: string }
type WithoutMeta<T> = { [K in keyof T as Exclude<K, `_${string}`>]: T[K] };
Практичні застосування:
- обробники подій з полів:
{ [K in keyof State as \on${Capitalize<...>}Change`]: (value: State[K]) => void }`; - типи форм: з моделі даних - тип помилок
{ [K in keyof T]?: string[] }(як помилки валідації Laravel); - вибір полів за типом значень: лише числові поля для сортування, лише булеві для фільтрів.
Важлива деталь: mapped type вигляду { [K in keyof T]: ... } (гомоморфний) зберігає модифікатори вихідного типу - readonly і ? переходять автоматично. Якщо перебирати не keyof T, а довільне об'єднання ([K in 'a' | 'b']), модифікатори не копіюються.
Масиви й кортежі у гомоморфних mapped types лишаються масивами й кортежами - тип застосовується до елементів, а не до методів масиву.
Докладніше в документації: Mapped types: перейменування ключів через as
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії