Middle: питання на співбесіді з теми «Просунуті типи»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Умовний тип вибирає один з двох типів залежно від перевірки - тернарний оператор на рівні типів:
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 з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.