Senior: питання на співбесіді з теми «Патерни й типобезпека»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Наївна шина подій - on(event: string, handler: (data: any) => void): назву події легко переплутати, а дані не типізовані. Мета - щоб для кожної події тип даних виводився автоматично.
Карта подій як тип:
type AppEvents = {
'user:created': { id: number; email: string };
'cart:updated': { count: number };
'session:expired': void;
};
Типізована шина:
class EventBus<E extends Record<string, unknown>> {
private handlers: { [K in keyof E]?: Array<(payload: E[K]) => void> } = {};
on<K extends keyof E>(event: K, handler: (payload: E[K]) => void): () => void {
(this.handlers[event] ??= []).push(handler);
return () => {
this.handlers[event] = this.handlers[event]?.filter((h) => h !== handler);
};
}
emit<K extends keyof E>(event: K, ...args: E[K] extends void ? [] : [payload: E[K]]): void {
for (const handler of this.handlers[event] ?? []) {
handler(args[0] as E[K]);
}
}
}
const bus = new EventBus<AppEvents>();
bus.on('user:created', (user) => user.email); // user: { id; email }
bus.emit('cart:updated', { count: 3 });
bus.emit('session:expired'); // без аргументу
bus.emit('cart:updated', { count: '3' }); // помилка
bus.on('user:deleted', () => {}); // помилка: немає такої події
Що тут працює:
K extends keyof E- назва події з'єднує виклик з типом даних:E[K]- дані саме цієї події;- mapped type
{ [K in keyof E]?: ... }- сховище обробників, типізоване для кожної події окремо; - кортеж залишкових параметрів
E[K] extends void ? [] : [payload: E[K]]- для подій без даних аргумент не потрібен, для решти обов'язковий; - повернена функція відписки - зручно для
useEffectчиonUnmounted.
Варіації:
- шаблонні літеральні типи для груп подій:
Extract<keyof E, \cart:${string}`>` - підписка на всі події кошика; EventTarget+CustomEvent- браузерна реалізація; типізувати її можна тим самим прийомом через перевантаженняaddEventListener;- готові бібліотеки (
mitt,nanoevents) приймають таку саму карту подій генеріком.
Де ще працює цей патерн «карта типів + keyof»: типізовані маршрути API (Endpoints['GET /users']), повідомлення postMessage між вікнами чи воркерами, події WebSocket-каналів, ключі localStorage з типами значень.
У fluent API (будівник запитів, конструктор форм, конфігурація) кожен виклик повертає об'єкт для наступного кроку. З генеріками кожен крок може уточнювати тип результату.
Приклад - запит, що знає, які поля вибрано:
type User = { id: number; name: string; email: string; passwordHash: string };
class Query<T extends object, Selected extends keyof T = never> {
constructor(private readonly fields: ReadonlyArray<keyof T> = []) {}
select<K extends keyof T>(...keys: K[]): Query<T, Selected | K> {
return new Query<T, Selected | K>([...this.fields, ...keys]);
}
async get(): Promise<Array<Pick<T, Selected>>> {
/* виконати запит з this.fields */
return [];
}
}
const users = await new Query<User>().select('id').select('name', 'email').get();
// users: Array<{ id: number; name: string; email: string }>
users[0].passwordHash; // помилка: поле не вибрано
new Query<User>().select('nope'); // помилка: такого поля немає
Ключова ідея: генерічний параметр (Selected) - «акумулятор». Кожен метод повертає новий тип з доповненим акумулятором (Selected | K). Через це методи мають повертати новий екземпляр з новим типом, а не this.
Обов'язкові кроки й порядок викликів - так само через параметри-прапорці:
class RequestBuilder<HasUrl extends boolean = false> {
declare private readonly hasUrl: HasUrl; // «фантомне» поле: лише для типів
url(value: string): RequestBuilder<true> { /* ... */ return this as unknown as RequestBuilder<true>; }
send(this: RequestBuilder<true>): Promise<Response> { /* ... */ }
}
new RequestBuilder().send(); // помилка: спочатку url()
new RequestBuilder().url('/api').send(); // ок
Параметр this у методі обмежує, на якому «етапі» метод доступний. Пастка: без поля hasUrl параметр HasUrl ніде не використовується в структурі класу - і через структурну типізацію RequestBuilder<false> вважається сумісним з RequestBuilder<true>, тож заборона мовчки не працює. Генерічний параметр-«прапорець» має бути частиною форми типу.
Де це використовується: Drizzle і Kysely (типізовані SQL-запити), tRPC, Zod (z.object(...).extend(...) накопичує форму), конструктори форм.
Ціна й межі:
- складні типи сповільнюють компілятор і редактор: глибокі ланцюжки з умовними типами на кожному кроці помітно гальмують підказки;
- повідомлення про помилки стають довгими й важкими для читання - типовий біль бібліотек на кшталт ORM;
- для власного коду застосунку часто досить простішого рішення: об'єкт параметрів з точним типом замість ланцюжка викликів.
Такий підхід виправданий у бібліотеках і спільних інструментах, які використовують багато разів, - там вкладення в типи окупається.
Типи бібліотек бувають неповними, застарілими чи надто загальними (any). Є кілька способів це виправити - від найбезпечнішого до найризикованішого.
1. Обгортка з точними типами - найнадійніше:
import { get } from 'legacy-http'; // повертає Promise<any>
export async function fetchJson<T>(url: string, schema: z.ZodType<T>): Promise<T> {
return schema.parse(await get(url));
}
Решта коду працює з вашою функцією, а не з бібліотекою. any локалізовано в одному місці і перевірено під час виконання.
2. Доповнення модуля (module augmentation) - додати до існуючих типів те, що бібліотека дозволяє розширювати:
// types/vue-router.d.ts
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean;
title?: string;
}
}
Працює лише з інтерфейсами (їх можна «доповнювати» через злиття оголошень), а не з аліасами type. Багато бібліотек навмисно лишають такі «точки розширення»: RouteMeta у Vue Router, ComponentCustomProperties у Vue, Register у TanStack Router, теми в styled-components.
3. Глобальні доповнення:
declare global {
interface Window {
analytics?: { track(event: string, props?: Record<string, unknown>): void };
}
}
export {};
4. Оголошення для пакета без типів:
// types/untyped-lib.d.ts
declare module 'untyped-lib' {
export function format(value: number, options?: { currency?: string }): string;
}
Краще описати лише використану частину API, ніж declare module 'untyped-lib'; (усе стає any).
5. Латка пакета (patch-package, pnpm patch) - виправити .d.ts прямо в node_modules. Крайній засіб: латку треба підтримувати при кожному оновленні.
Що варто знати:
- файл з доповненням має бути модулем (мати
importчиexport), інакшеdeclare moduleстворить новий модуль замість доповнення існуючого; - файл має потрапити в компіляцію - через
includeуtsconfig; - з TypeScript 6
typesза замовчуванням порожній - глобальні типи з@types/*(наприклад,@types/node) треба перелічувати явно в"types": ["node"]; - внесок в оригінал: виправлення типів у DefinitelyTyped чи в саму бібліотеку прибирає потребу в латках для всіх.
Система типів TypeScript тюрінг-повна: на рівні типів можна парсити рядки, рахувати й будувати складні перетворення. Це не означає, що так варто робити в коді застосунку.
Ознаки переускладнених типів:
- тип важче зрозуміти, ніж код, який він описує. Новий розробник витрачає годину, щоб зрозуміти, чому помилка компіляції;
- повідомлення про помилки на десятки рядків з вкладеними умовними типами - замість «очікувалося число»;
- редактор гальмує: підказки з'являються з затримкою,
tscпрацює хвилинами; as anyпоруч зі складним типом - ознака, що тип не впорався з реальністю;- типи заради типів: рекурсивні утиліти для одного виклику, які можна замінити явним інтерфейсом.
Принципи розумної типізації:
- простіше - краще: явний
interfaceз переліком полів читабельніший заOmit<Pick<A, ...> & Partial<B>, ...>. Дублювання кількох полів інколи дешевше за складну похідну; - складність - у бібліотеках, простота - у застосунку. Генеріки й умовні типи виправдані в спільних інструментах (клієнт API, будівник форм), які використовуються сотні разів;
- типи на межах, виведення всередині: явно типізувати публічні функції, параметри, повернені значення модулів - а всередині функцій покладатися на виведення;
- дані з зовнішнього світу - схема валідації (Zod), з якої виводиться тип, а не вручну написаний «ідеальний» тип;
- читабельні назви проміжних типів замість одного гігантського виразу.
Продуктивність компілятора (рекомендації з вікі TypeScript):
- інтерфейси замість перетинів (
interface A extends B, CзамістьB & C) - їх відношення кешуються; - явні типи повернення у великих функціях - компілятору не треба виводити їх щоразу;
- уникати великих об'єднань (сотні варіантів) і глибокої рекурсії в умовних типах;
tsc --extendedDiagnosticsі--generateTrace- знайти, які файли й типи забирають найбільше часу.
TypeScript 7 (нативний компілятор на Go) пришвидшив перевірку в рази, але це не скасовує проблему: складні типи все одно важко читати й підтримувати, а помилки в них - розуміти.
Тест на доречність: чи стане коду помітно безпечніше від цього типу, і чи зрозуміє його колега без вашої допомоги? Якщо на обидва питання відповідь «ні» - простіший тип кращий.