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