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

Senior: питання на співбесіді з теми «Просунуті типи»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Тип може посилатися сам на себе - так описують деревоподібні структури.

Значення 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-параметри типу