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

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

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

5 питань

TypeScript свідомо не є повністю надійним (sound): заради зручності й сумісності з JavaScript він пропускає деякі програми, що впадуть під час виконання. Важливо знати, де саме.

1. Доступ за індексом:

const items: string[] = [];
const first: string = items[0];   // компілюється, хоча first - undefined
first.toUpperCase();              // падіння

Закриває: "noUncheckedIndexedAccess": true - тоді items[0] має тип string | undefined. Те саме для об'єктів з індексною сигнатурою (Record<string, T>).

2. Коваріантність змінних масивів:

const dogs: Dog[] = [];
const animals: Animal[] = dogs;   // дозволено
animals.push(new Cat());          // тепер у масиві dogs - кіт

Закриває: приймати readonly Animal[] у функціях, що не змінюють масив.

3. Біваріантність параметрів методів:

interface Handler {
  handle(event: Event): void;    // синтаксис методу - біваріантний
}
const h: Handler = { handle(e: MouseEvent) { e.clientX; } };   // дозволено

strictFunctionTypes (частина strict) перевіряє параметри контраваріантно, але лише для властивостей-функцій (handle: (e: Event) => void), а не для синтаксису методів. Тому для колбеків у власних інтерфейсах краще синтаксис властивості.

4. any - вимикає перевірку для всього, до чого торкається, і «заражає» вирази. Закриває: unknown для невідомих даних, правила @typescript-eslint/no-unsafe-*, noImplicitAny.

5. Твердження типу as і ! - компілятор вірить розробнику.

6. Зовнішні дані - відповідь API типізована так, як ви написали, а не так, як прийшло. Закриває лише перевірка під час виконання (Zod, Valibot).

7. Інші місця: опціональні властивості й undefined (закриває exactOptionalPropertyTypes), мутація після звуження в замиканні, некоректні .d.ts бібліотек.

Практичний набір налаштувань для надійності:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}

З TypeScript 6 strict увімкнено за замовчуванням, але noUncheckedIndexedAccess і exactOptionalPropertyTypes - ні, їх варто додати явно в нових проєктах.

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

TypeScript порівнює типи за структурою, а не за назвою. Два типи з однаковою формою - взаємозамінні:

type UserId = string;
type OrderId = string;

function loadUser(id: UserId) { /* ... */ }

const orderId: OrderId = 'ord_123';
loadUser(orderId);   // жодної помилки - обидва просто string

Аліаси типів - лише інші назви того самого типу. Переплутати ідентифікатори, гроші в копійках і гривнях, сирий і екранований HTML компілятор не завадить.

Branded types додають до типу «мітку», якої не існує під час виконання, але яка робить типи несумісними:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function loadUser(id: UserId) { /* ... */ }

const orderId = 'ord_123' as OrderId;
loadUser(orderId);   // помилка: OrderId несумісний з UserId

Значення з міткою створюють лише в одному місці - функції-конструкторі, що перевіряє дані:

type Email = Brand<string, 'Email'>;

function toEmail(value: string): Email {
  if (!/^[^@\s]+@[^@\s]+$/.test(value)) {
    throw new Error(`Некоректна адреса: ${value}`);
  }
  return value as Email;   // єдине місце з приведенням
}

function sendWelcome(to: Email) { /* тут адреса гарантовано перевірена */ }

Тип Email тепер означає «перевірений рядок» - функції, що його приймають, не мусять перевіряти повторно.

Застосування:

  • ідентифікатори різних сутностей - не передати id замовлення туди, де потрібен id користувача;
  • одиниці виміру: Cents, Uah, Milliseconds, Seconds;
  • перевірені дані: Email, SafeHtml, NonEmptyString, PositiveInt;
  • токени й секрети, які не можна випадково записати в лог як звичайний рядок.

Що варто знати:

  • мітка існує лише в типах - під час виконання це звичайний рядок чи число, без витрат;
  • операції над значенням (id + '_x') повертають звичайний string - мітка губиться, і це правильно;
  • бібліотеки валідації підтримують мітки: z.string().email().brand<'Email'>() у Zod;
  • класи з приватними полями - номінальні за природою: два класи з однаковою формою, але різними #private полями, несумісні.

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

Проблема винятків у TypeScript: сигнатура функції не каже, які помилки вона може кинути. function parse(s: string): Config може кинути що завгодно, а в catch (error) змінна має тип unknown. Компілятор не змусить обробити помилку.

Тип Result робить помилку частиною повернутого значення:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

type AgeError = 'not-a-number' | 'negative' | 'too-large';

function parseAge(input: string): Result<number, AgeError> {
  const n = Number(input);
  if (Number.isNaN(n)) return { ok: false, error: 'not-a-number' };
  if (n < 0) return { ok: false, error: 'negative' };
  if (n > 150) return { ok: false, error: 'too-large' };
  return { ok: true, value: n };
}
const result = parseAge(form.age);

if (!result.ok) {
  showError(messages[result.error]);   // error: AgeError - відомо, які бувають
  return;
}
saveAge(result.value);                 // value: number - лише після перевірки

Що це дає:

  • помилки видно в сигнатурі - і їх неможливо «забути»: до value не дістатися без перевірки ok;
  • точні типи помилок - перелік очікуваних випадків, а не unknown;
  • вичерпна обробка: switch (result.error) з перевіркою never гарантує, що кожен варіант оброблено;
  • легко тестувати: функція повертає дані, а не кидає.

Коли Result виправданий:

  • очікувані бізнес-помилки: валідація, «товару немає на складі», «недостатньо коштів», відповіді API з відомими кодами;
  • місця, де помилку треба обробити поруч з викликом.

Коли краще винятки:

  • неочікувані збої (баги, недоступна мережа, порушені інваріанти) - їх обробляють на межі застосунку, а не в кожному виклику;
  • код, що працює з фреймворками, які очікують винятків (межі помилок React, обробники помилок у Laravel-стилі).

Пастки:

  • ланцюжки Result-ів многослівні (if (!r.ok) return r; на кожному кроці). Бібліотеки (neverthrow, Effect) дають map, andThen - але додають новий стиль коду, який команда має прийняти;
  • змішування стилів: функція, що повертає Result, але всередині може кинути виняток, - найгірший варіант. Межа має бути чіткою: на якому рівні винятки перетворюються на Result.

Докладніше в документації: Дискриміновані об'єднання

Помічник assertNever - замість того, щоб у кожному switch повторювати присвоєння never:

export function assertNever(value: never, message = 'Неочікуване значення'): never {
  throw new Error(`${message}: ${JSON.stringify(value)}`);
}

type PaymentMethod =
  | { type: 'card'; last4: string }
  | { type: 'bank'; iban: string }
  | { type: 'cash' };

function label(method: PaymentMethod): string {
  switch (method.type) {
    case 'card': return `Картка •${method.last4}`;
    case 'bank': return `Рахунок ${method.iban}`;
    case 'cash': return 'Готівка';
    default: return assertNever(method);
  }
}

Додали { type: 'crypto' } - компілятор вказує на assertNever(method): аргумент більше не never. А під час виконання (дані прийшли з API в обхід типів) - зрозумілий виняток замість тихого undefined.

Без switch - через об'єкт-мапу з повним набором ключів:

const icons = {
  card: 'credit-card',
  bank: 'building-columns',
  cash: 'money-bill',
} satisfies Record<PaymentMethod['type'], string>;

Новий варіант у типі - помилка, що в мапі бракує ключа.

switch (true) зі звуженням (TypeScript 5.3+). Для умов, які не зводяться до одного поля:

function describe(value: string | number | Date | null): string {
  switch (true) {
    case value === null:
      return '-';
    case typeof value === 'string':
      return value.trim();          // value: string
    case typeof value === 'number':
      return value.toFixed(2);      // value: number
    case value instanceof Date:
      return value.toISOString();   // value: Date
    default:
      return assertNever(value);
  }
}

Кожна гілка case звужує тип так само, як if. Це читабельніша альтернатива довгому ланцюжку if/else if.

Помічник match у функціональному стилі - типізований об'єкт обробників:

function match<T extends { type: string }, R>(
  value: T,
  handlers: { [K in T['type']]: (v: Extract<T, { type: K }>) => R },
): R {
  return (handlers as any)[value.type](value);
}

match(method, {
  card: (m) => m.last4,
  bank: (m) => m.iban,
  cash: () => 'готівка',
});   // пропущений обробник - помилка компіляції

Для складних випадків є бібліотека ts-pattern з повноцінним зіставленням зразків і перевіркою вичерпності.

Докладніше в документації: Звуження в switch (true)

Для бібліотек, спільних утиліт і складних генеріків типи - частина публічного API. Регресія в типі (функція почала повертати any, перестала ловити неправильний аргумент) - такий самий баг, як помилка в логіці. Звичайні тести її не помітять.

1. @ts-expect-error - перевірка, що помилка БУДЕ:

// @ts-expect-error - id має бути числом
loadUser('42');

Якщо рядок перестане бути помилкою, компілятор скаже Unused '@ts-expect-error' directive (TS2578). Так тестують, що тип забороняє неправильне використання.

Не плутати з @ts-ignore: той мовчки приховує будь-яку помилку і не скаже, якщо її вже немає. У коді застосунку @ts-ignore - майже завжди погана ідея; @ts-expect-error з поясненням чесніший.

2. expect-type - перевірка, що тип ТОЧНО такий:

import { expectTypeOf } from 'expect-type';

const row = queryBuilder.select('id', 'name').build();

expectTypeOf(row).toEqualTypeOf<{ id: number; name: string }>();
expectTypeOf(parseAge).returns.toEqualTypeOf<Result<number, AgeError>>();
expectTypeOf(useCart).toBeFunction();

Перевірка відбувається під час компіляції (tsc --noEmit). У Vitest той самий API вбудований (expectTypeOf), а режим vitest --typecheck запускає файли *.test-d.ts як типові тести.

3. tsd - окремий інструмент для .d.ts бібліотек: перевіряє типи з погляду споживача пакета (expectType, expectError). Використовує вбудовану версію компілятора, тож результат не залежить від TypeScript у проєкті.

Що тестувати:

  • виведення типів генеріків і перевантажень - результат має бути точним, а не any чи unknown;
  • заборонені виклики - неправильні аргументи мають давати помилку;
  • звуження: після type guard тип звужено правильно;
  • утиліти типів (DeepPartial, PathOf<T>) - на граничних випадках: never, об'єднання, порожні об'єкти, any.

Пастки:

  • перевірка «дорівнює» складніша, ніж здається: any сумісний з усім, тож наївна перевірка через присвоєння пропустить any. toEqualTypeOf враховує це;
  • тести типів запускаються лише в CI з tsc - Vite і esbuild при збиранні типи не перевіряють. Без кроку tsc --noEmit у CI типові тести нічого не ловлять.

Докладніше в документації: Коментарі @ts-expect-error