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 з повноцінним зіставленням зразків і перевіркою вичерпності.
Для бібліотек, спільних утиліт і складних генеріків типи - частина публічного 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 типові тести нічого не ловлять.