Питання на співбесіді: Просунуті типи
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
keyof T - тип-об'єднання всіх ключів типу T:
interface User {
id: number;
name: string;
email: string;
}
type UserKey = keyof User; // 'id' | 'name' | 'email'
Головне застосування - функції, що працюють з ключами об'єкта безпечно:
function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: 'Оля', email: 'olia@example.com' };
getField(user, 'name'); // string
getField(user, 'id'); // number
getField(user, 'phone'); // помилка: 'phone' немає в keyof User
Тип повернення T[K] точно відповідає переданому ключу - не string | number, а саме тип цього поля.
Інші застосування:
- сортування й групування таблиць за назвою колонки:
sortBy<T>(items: T[], key: keyof T); - перелік дозволених полів для фільтрів, форм, експорту;
- основа mapped types:
{ [K in keyof T]: ... }.
Особливості:
- з індексною сигнатурою
keyofдає ширший тип. Для{ [key: string]: number }результат -string | number, бо в JavaScript числові ключі об'єкта перетворюються на рядки (obj[1]- те саме, щоobj['1']); - для масивів
keyof string[]міститьnumberі назви методів масиву ('length' | 'push' | ...); - лише ключі типу, а не значення:
keyofпрацює з типами. Щоб отримати ключі значення, потрібнеkeyof typeof config.
Пастка з Object.keys: Object.keys(user) повертає string[], а не (keyof User)[]. Це навмисно: через структурну типізацію в об'єкті під час виконання можуть бути й інші поля, яких немає в типі. Тому for (const key of Object.keys(user)) user[key] - помилка типу, і доводиться або звужувати ключ, або використовувати Object.entries.
У позиції типу typeof повертає тип значення (а не рядок 'object', як у JavaScript під час виконання):
const config = {
apiUrl: '/api',
retries: 3,
features: { chat: true },
};
type Config = typeof config;
// { apiUrl: string; retries: number; features: { chat: boolean } }
Навіщо: значення стає джерелом правди, а тип - похідним. Додали поле в об'єкт - тип оновився сам, дублювати опис не треба.
Типові застосування:
1. Тип функції чи її частин:
function createUser(name: string, role: 'admin' | 'editor') {
return { id: crypto.randomUUID(), name, role, createdAt: new Date() };
}
type User = ReturnType<typeof createUser>;
type CreateUserArgs = Parameters<typeof createUser>;
2. Перелік значень з масиву чи об'єкта:
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number]; // 'admin' | 'editor' | 'viewer'
const STATUS = { draft: 'Чернетка', published: 'Опубліковано' } as const;
type StatusKey = keyof typeof STATUS; // 'draft' | 'published'
3. Тип схеми валідації: z.infer<typeof userSchema> - тип з об'єкта Zod.
4. Тип модуля чи класу: typeof import('./api'), typeof User (конструктор, а не екземпляр).
Обмеження:
typeofу позиції типу приймає лише ідентифікатор чи доступ до властивості (typeof config.features), але не довільний вираз:typeof fn()- помилка. Для результату виклику -ReturnType<typeof fn>;- розширення типів: без
as constрядки й числа в об'єкті виводяться якstring,number. Якщо потрібні точні літерали -as const.
Обидва typeof в одному рядку:
if (typeof value === 'string') {} // typeof JavaScript - перевірка під час виконання
type T = typeof value; // typeof TypeScript - тип під час компіляції
Це різні оператори з однаковою назвою: перший працює в коді, другий - лише в позиції типу.
Індексований тип доступу - отримати тип властивості іншого типу тим самим синтаксисом, що й доступ до поля об'єкта:
interface Order {
id: number;
status: 'new' | 'paid' | 'shipped';
customer: { name: string; email: string };
items: { sku: string; qty: number }[];
}
type OrderStatus = Order['status']; // 'new' | 'paid' | 'shipped'
type Customer = Order['customer']; // { name: string; email: string }
type CustomerEmail = Order['customer']['email']; // string
type IdOrStatus = Order['id' | 'status']; // number | 'new' | 'paid' | 'shipped'
T[number] - тип елемента масиву:
type OrderItem = Order['items'][number]; // { sku: string; qty: number }
const SIZES = ['sm', 'md', 'lg'] as const;
type Size = (typeof SIZES)[number]; // 'sm' | 'md' | 'lg'
number як індекс означає «будь-який числовий індекс» - тобто тип будь-якого елемента. Для кортежів - об'єднання типів усіх елементів.
Навіщо це:
- не дублювати описи: тип статусу береться з
Order, і якщо статуси зміняться, похідні типи оновляться; - типи для частин відповіді API: згенерований тип відповіді великий, а компоненту потрібна одна вкладена частина -
ApiResponse['data']['items'][number]; - поєднання з
keyof:T[keyof T]- об'єднання типів усіх значень об'єкта.
const LABELS = { draft: 'Чернетка', published: 'Опубліковано' } as const;
type Label = (typeof LABELS)[keyof typeof LABELS]; // 'Чернетка' | 'Опубліковано'
Обмеження:
- індекс - тип, а не значення:
Order[key], деkey- змінна, не працює. ПотрібноOrder[typeof key]або параметр типу; - лише існуючі ключі:
Order['phone']- помилка; - необов'язкове поле дає тип з
undefined: дляemail?: string-string | undefined. Прибрати -NonNullable<User['email']>.
Різниця з доступом під час виконання: noUncheckedIndexedAccess додає | undefined до доступу до масиву за індексом у коді, але T[number] у типах повертає тип елемента без undefined.
Літеральний тип - тип, що допускає одне конкретне значення:
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Code = 200 | 404 | 500;
function request(method: Method, url: string) {}
request('GET', '/api/users');
request('get', '/api/users'); // помилка: 'get' не входить у Method
Об'єднання літералів - основний спосіб описати скінченний набір значень у TypeScript.
Шаблонні рядкові типи (template literal types) будують рядкові типи за шаблоном - як шаблонні рядки JavaScript, але на рівні типів:
type Size = 'sm' | 'md' | 'lg';
type Color = 'red' | 'blue';
type ClassName = `btn-${Size}-${Color}`;
// 'btn-sm-red' | 'btn-sm-blue' | 'btn-md-red' | ... - усі 6 комбінацій
type EventName = `on${Capitalize<'click' | 'focus'>}`; // 'onClick' | 'onFocus'
type UserRoute = `/users/${number}`;
const ok: UserRoute = '/users/42';
const bad: UserRoute = '/users/abc'; // помилка
Вбудовані типи для рядків: Uppercase, Lowercase, Capitalize, Uncapitalize.
Практичні застосування:
- ключі подій і перекладів:
`auth.${'login' | 'logout'}.title`- помилка в ключі ловиться компілятором; - CSS-значення:
`${number}px` | `${number}%`; - ідентифікатори з префіксом:
`usr_${string}`,`ord_${string}`- різні типи ID не сплутаються; - разом з mapped types - генерація назв методів (
getName,setName) з полів.
Обмеження:
- комбінаторний вибух: кожна позиція множить варіанти. Об'єднання на десятки тисяч рядків уповільнює компілятор, а понад 100 000 членів - помилка «Expression produces a union type that is too complex to represent»;
stringу шаблоні (`usr_${string}`) перевіряє лише форму, а не вміст:usr_з будь-яким продовженням підходить;- лише компіляція: рядки з API чи URL під час виконання не перевіряються - потрібна валідація.
Параметр типу може бути обмежений іншим параметром - так описують зв'язок між аргументами функції.
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const users = [
{ id: 1, name: 'Оля', age: 28 },
{ id: 2, name: 'Іра', age: 31 },
];
pluck(users, 'name'); // string[]
pluck(users, 'age'); // number[]
pluck(users, 'email'); // помилка: 'email' немає серед ключів
Як це працює:
- TypeScript виводить
Tз першого аргументу - тип елементів масиву; K extends keyof Tдозволяє другим аргументом лише ключіT;Kвиводиться як конкретний літерал ('name'), а не весьkeyof T, тож тип поверненняT[K][]- точний.
Інші приклади зв'язаних параметрів:
// значення має відповідати типу поля
function setField<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
obj[key] = value;
}
setField(user, 'age', 30); // ок
setField(user, 'age', '30'); // помилка: string не number
// групування
function groupBy<T, K extends keyof T>(items: T[], key: K): Map<T[K], T[]> {
const groups = new Map<T[K], T[]>();
for (const item of items) {
const group = groups.get(item[key]) ?? [];
group.push(item);
groups.set(item[key], group);
}
return groups;
}
Типові помилки:
key: keyof Tбез окремого параметра:function getField<T>(obj: T, key: keyof T): T[keyof T]- тип повернення стає об'єднанням типів усіх полів. ОкремийKзберігає зв'язок з конкретним ключем;K extends stringзамістьkeyof T- тоді немає перевірки, що ключ існує;- символьні й числові ключі:
keyof Tможе міститиnumber | symbol. Якщо ключ використовується в рядкових операціях (шаблонні рядки), -K extends keyof T & stringабоExtract<keyof T, string>.
Цей прийом - основа багатьох бібліотек: типізовані таблиці (columnHelper.accessor('email')), форми (register('name') у React Hook Form), сховища стану з селекторами за ключем.
Докладніше в документації: Generics: параметри типу в обмеженнях
Умовний тип вибирає один з двох типів залежно від перевірки - тернарний оператор на рівні типів:
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
Крім утиліт для об'єктів (Partial, Pick, Omit, Record), TypeScript має утиліти для функцій, промісів і керування виведенням типів.
Функції й класи:
async function fetchUser(id: number, withPosts = false) {
return { id, name: 'Оля', posts: withPosts ? [] : undefined };
}
type Args = Parameters<typeof fetchUser>; // [id: number, withPosts?: boolean]
type Result = ReturnType<typeof fetchUser>; // Promise<{ ... }>
type UserData = Awaited<ReturnType<typeof fetchUser>>; // { id: number; name: string; ... }
class Service { constructor(public url: string) {} }
type CtorArgs = ConstructorParameters<typeof Service>; // [url: string]
type Instance = InstanceType<typeof Service>; // Service
Awaited<T> рекурсивно розгортає проміси: Awaited<Promise<Promise<number>>> - number. Так само типізовано await і Promise.all.
Об'єднання:
Exclude<T, U>- прибрати з об'єднання;Extract<T, U>- залишити лише відповідні;NonNullable<T>- прибратиnullіundefined.
NoInfer<T> (TS 5.4) - заборонити виводити параметр типу з цієї позиції:
function createSelect<T extends string>(options: T[], defaultValue: NoInfer<T>) {}
createSelect(['sm', 'md', 'lg'], 'md'); // ок
createSelect(['sm', 'md', 'lg'], 'xl'); // помилка
Без NoInfer TypeScript вивів би T з обох аргументів - як 'sm' | 'md' | 'lg' | 'xl' - і помилки не було б. NoInfer каже: тип визначають лише варіанти, а значення за замовчуванням має їм відповідати.
Рядки: Uppercase, Lowercase, Capitalize, Uncapitalize - для шаблонних рядкових типів.
ThisParameterType, OmitThisParameter - для функцій з типізованим this.
Навіщо знати утиліти:
- похідні типи замість дублювання: тип відповіді API береться з функції запиту, тип аргументів - з функції, що їх приймає;
- типи з бібліотек без експорту: якщо бібліотека не експортує тип опцій,
Parameters<typeof libFn>[0]дістане його; - читабельність: стандартні назви зрозумілі всім, на відміну від власних умовних типів.
Пастка ReturnType з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.
Тип може посилатися сам на себе - так описують деревоподібні структури.
Значення JSON:
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
const data: Json = { users: [{ id: 1, tags: ['php'], manager: null }] };
const bad: Json = { createdAt: new Date() }; // помилка: Date не є Json
Корисно для функцій, що приймають «будь-який серіалізовний об'єкт» (кеш, localStorage, postMessage) - тоді Date, Map, функції не пройдуть.
Глибокі утиліти:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Settings = { theme: { colors: { primary: string; accent: string } } };
const patch: DeepPartial<Settings> = { theme: { colors: { accent: '#f00' } } };
Деревоподібні дані:
type Category = { id: number; name: string; children: Category[] };
type MenuItem = { label: string; href?: string; items?: MenuItem[] };
Обмеження й пастки:
- функції, дати,
Map-T[K] extends objectістинне і для них.DeepReadonly<Date>спробує обійти методиDate. Їх виключають окремими гілками:T[K] extends Function | Date | Map<any, any> ? T[K] : ...; - масиви в mapped types обробляються як масиви (гомоморфні типи), але кортежі й readonly-масиви інколи потребують окремої обробки;
- глибина інстанціювання: компілятор обмежує глибину рекурсії. Для дуже глибоких чи «вибухових» типів з'являється «Type instantiation is excessively deep and possibly infinite». Хвостова рекурсія в умовних типах (TS 4.5+) дозволяє до ~1000 рівнів, а звичайна обривається значно раніше - на кількох десятках;
- продуктивність: рекурсивні типи над великими структурами (типи відповідей API на сотні полів) помітно сповільнюють редактор. Повільна підказка в IDE - ознака, що тип надто «розумний»;
- рекурсивні псевдоніми (
type Json = ... Json[]) підтримуються з TS 3.7; раніше доводилося обходити через інтерфейси.
Порада: рекурсивні типи - для справді рекурсивних даних. Якщо рекурсія потрібна лише для «розумної» перевірки, варто зважити, чи вартий виграш у типах повільнішого редактора й незрозумілих повідомлень про помилки.
Шаблонні рядкові типи разом з mapped types дають змогу генерувати API з опису даних - і компілятор перевіряє назви, які раніше були «просто рядками».
Обробники змін для кожного поля стану:
type State = { name: string; age: number; subscribed: boolean };
type ChangeHandlers<T> = {
[K in keyof T & string as `on${Capitalize<K>}Change`]: (value: T[K]) => void;
};
type Handlers = ChangeHandlers<State>;
// {
// onNameChange: (value: string) => void;
// onAgeChange: (value: number) => void;
// onSubscribedChange: (value: boolean) => void;
// }
Типізований емітер подій:
type Events = {
'order:created': { orderId: number };
'order:paid': { orderId: number; amount: number };
'user:logout': undefined;
};
class Emitter<E extends Record<string, unknown>> {
on<K extends keyof E & string>(event: K, handler: (payload: E[K]) => void) { /* ... */ }
emit<K extends keyof E & string>(event: K, payload: E[K]) { /* ... */ }
}
const bus = new Emitter<Events>();
bus.on('order:paid', ({ amount }) => {}); // amount: number
bus.emit('order:payd', { orderId: 1 }); // помилка: друкарська помилка в назві
Вибір підмножини подій за префіксом:
type OrderEvents = Extract<keyof Events, `order:${string}`>; // 'order:created' | 'order:paid'
Ключі перекладів з вкладеного об'єкта:
type Paths<T> = {
[K in keyof T & string]: T[K] extends object ? `${K}.${Paths<T[K]>}` : K;
}[keyof T & string];
const uk = { auth: { login: 'Увійти', logout: 'Вийти' }, nav: { home: 'Головна' } };
type Key = Paths<typeof uk>; // 'auth.login' | 'auth.logout' | 'nav.home'
declare function t(key: Key): string;
t('auth.login'); // ок
t('auth.lgoin'); // помилка
Прийом { ... }[keyof T] - обчислити mapped type і взяти об'єднання всіх значень.
Де межа корисності:
- розмір об'єднань: сотні ключів перекладів на кілька рівнів вкладеності - сповільнення компілятора й редактора. Бібліотеки i18n часто генерують такі типи окремим кроком збирання;
- повідомлення про помилки для складних типів бувають нечитабельними - варто мати прості іменовані проміжні типи;
- динамічні ключі (зібрані з даних під час виконання) не перевіряються - знадобиться приведення типу чи валідація.
Де це приносить найбільшу користь: ключі перекладів, назви подій і каналів, маршрути, CSS-змінні дизайн-системи - рядки, помилки в яких інакше знаходяться лише під час виконання.
Варіантність описує, як підтипи узагальненого типу пов'язані з підтипами його параметра. Нехай Dog - підтип Animal.
- Коваріантність (як у «виробника» значень):
Producer<Dog>можна використати там, де очікуєтьсяProducer<Animal>. ПоверненийDog- тежAnimal; - контраваріантність (як у «споживача»): навпаки,
Consumer<Animal>підходить замістьConsumer<Dog>. Функція, що вміє обробити будь-яку тварину, обробить і собаку; - інваріантність - коли тип і читається, і записується: жоден з варіантів не безпечний.
Параметри функцій - контраваріантні (з strictFunctionTypes, що входить у strict):
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
type Handler = (animal: Animal) => void;
const dogHandler = (dog: Dog) => dog.bark();
const h: Handler = dogHandler; // помилка: обробнику передадуть кота, а він викличе bark()
Але методи - біваріантні, і це свідома діра в системі типів:
interface WithMethod { handle(animal: Animal): void } // синтаксис методу
interface WithProperty { handle: (animal: Animal) => void } // властивість-функція
const m: WithMethod = { handle: dogHandler }; // компілюється!
const p: WithProperty = { handle: dogHandler }; // помилка
strictFunctionTypes перевіряє лише типи, оголошені як функції-властивості. Методи лишено біваріантними для сумісності з величезною кількістю наявного коду (наприклад, Array<T>.push(item: T) - метод, і без біваріантності Dog[] не був би сумісний з Animal[]).
Масиви коваріантні - і через це несоундні:
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // дозволено
animals.push({ name: 'Мурка' }); // кіт потрапив у масив собак
dogs[0].bark(); // падіння під час виконання
readonly Animal[] прибирає проблему: в нього не можна записувати.
Анотації варіантності (TS 4.7+) - явно вказати варіантність параметра:
interface Producer<out T> { get(): T }
interface Consumer<in T> { accept: (value: T) => void }
interface State<in out T> { get(): T; set: (v: T) => void }
Вони прискорюють перевірку великих узагальнених типів і перевіряють, що оголошення відповідає заявленій варіантності. Але через біваріантність методів анотація out T поряд з методом set(v: T) помилки не дасть - лише з властивістю-функцією.
Практичні висновки: у власних інтерфейсах для колбеків надавати перевагу синтаксису властивості (onChange: (v: T) => void) - отримаєте строгішу перевірку; для параметрів, які функція лише читає, - readonly масиви.
За замовчуванням TypeScript виводить параметри типу розширено: літерали стають string, масиви - звичайними масивами.
function routes<T extends readonly string[]>(paths: T): T {
return paths;
}
const r = routes(['/home', '/about']); // string[] - назви маршрутів втрачено
Раніше викликач мусив писати as const на кожному виклику: routes(['/home', '/about'] as const).
Константний параметр типу (TS 5.0) переносить цю вимогу в оголошення функції:
function routes<const T extends readonly string[]>(paths: T): T {
return paths;
}
const r = routes(['/home', '/about']); // readonly ['/home', '/about']
type Route = (typeof r)[number]; // '/home' | '/about'
Аргумент виводиться так, ніби його записано з as const.
Важлива деталь - readonly в обмеженні. Якщо обмеження - змінюваний масив (T extends string[]), const не допоможе: readonly-кортеж не можна присвоїти string[], і TypeScript відкотиться до string[]. Тому обмеження пишуть як readonly unknown[].
Де корисно:
- конфігурації й реєстри: маршрути, ключі подій, назви полів форм, налаштування таблиць - тип будується з літералів, переданих викликачем;
- builder-API:
defineConfig({ ... }),createRoutes([...]),createStore({ ... })- бібліотеки отримують точні типи безas constу користувача.
Інші способи керувати виведенням:
NoInfer<T>(TS 5.4) - заборонити виводитиTз певного аргументу, щоб той лише перевірявся;- обмеження з літералами:
T extends 'sm' | 'md' | 'lg'- тодіTвиводиться як конкретний літерал, а неstring; - порядок аргументів: TypeScript виводить параметри зліва направо, і колбеки з неанотованими параметрами отримують типи від попередніх аргументів. Тому функції на кшталт
useQuery(key, fetcher)чиpipe(value, ...fns)проєктують так, щоб «джерело» типу йшло першим; - явні аргументи типу
fn<User>(...)- останній варіант: TypeScript не підтримує часткового виведення, тож явно доведеться вказати всі параметри без значень за замовчуванням.
Пастка: const не робить значення незмінним під час виконання - лише впливає на виведений тип (з readonly в ньому).
Докладніше в документації: TypeScript 5.0: const-параметри типу