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

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 з типами значень.

Докладніше в документації: Mapped types

У 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;
  • для власного коду застосунку часто досить простішого рішення: об'єкт параметрів з точним типом замість ланцюжка викликів.

Такий підхід виправданий у бібліотеках і спільних інструментах, які використовують багато разів, - там вкладення в типи окупається.

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

Типи бібліотек бувають неповними, застарілими чи надто загальними (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) пришвидшив перевірку в рази, але це не скасовує проблему: складні типи все одно важко читати й підтримувати, а помилки в них - розуміти.

Тест на доречність: чи стане коду помітно безпечніше від цього типу, і чи зрозуміє його колега без вашої допомоги? Якщо на обидва питання відповідь «ні» - простіший тип кращий.

Докладніше в документації: Продуктивність TypeScript