Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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 з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.

Докладніше в документації: Utility Types: NoInfer