Питання на співбесіді: Патерни й типобезпека
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
satisfies перевіряє, що значення відповідає типу, але не замінює виведений тип значення на цей тип.
Проблема з анотацією:
type Route = { path: string; auth?: boolean };
const routes: Record<string, Route> = {
home: { path: '/' },
admin: { path: '/admin', auth: true },
};
routes.admin.path; // ok
routes.nope.path; // теж компілюється - тип каже «будь-який рядок-ключ»
Анотація Record<string, Route> «стерла» знання про конкретні ключі: TypeScript більше не знає, що є лише home і admin.
З satisfies:
const routes = {
home: { path: '/' },
admin: { path: '/admin', auth: true },
} satisfies Record<string, Route>;
routes.admin.auth; // ok
routes.nope; // помилка: такого ключа немає
Об'єкт перевірено на відповідність Route (друкарська помилка в pth чи auth: 'yes' дасть помилку), а тип лишився точним - з конкретними ключами й значеннями.
Де це корисно:
- конфігурації й мапи: маршрути, переклади, налаштування таблиць, словники статусів;
- разом з
as const- точні літеральні типи плюс перевірка форми:
const statusColors = {
paid: 'green',
pending: 'amber',
} as const satisfies Record<OrderStatus, string>;
Якщо в OrderStatus з'явиться новий статус, TypeScript вкаже, що мапа неповна.
Порівняння трьох способів:
| Запис | Перевірка | Тип значення |
|---|---|---|
const x: T = ... |
так | T (широкий) |
const x = ... as T |
майже ні | T |
const x = ... satisfies T |
так | точний виведений |
as тут - найгірший варіант: приведення типу пропускає помилки, які satisfies і анотація зловили б.
as const каже TypeScript вивести найвужчий тип і зробити все лише для читання:
const roles = ['admin', 'editor', 'viewer']; // string[]
const roles2 = ['admin', 'editor', 'viewer'] as const; // readonly ['admin', 'editor', 'viewer']
type Role = (typeof roles2)[number]; // 'admin' | 'editor' | 'viewer'
roles2.push('guest'); // помилка: масив лише для читання
Головний прийом - одне джерело правди для значень і типу. Список значень потрібен і під час виконання (випадний список, валідація), і як тип. З as const тип виводиться зі значень, і вони не розходяться:
const ORDER_STATUSES = ['new', 'paid', 'shipped'] as const;
type OrderStatus = (typeof ORDER_STATUSES)[number];
function isOrderStatus(value: string): value is OrderStatus {
return (ORDER_STATUSES as readonly string[]).includes(value);
}
Це часто краща альтернатива enum: звичайний JavaScript-масив без особливого синтаксису.
Об'єкти:
const config = { api: { timeout: 5000 } } as const;
config.api.timeout = 10; // помилка - readonly на всіх рівнях
readonly у типах - для параметрів і полів, які функція не повинна змінювати:
function total(items: readonly CartItem[]): number {
items.sort(); // помилка: sort змінює масив
return items.reduce((sum, i) => sum + i.price, 0);
}
interface User {
readonly id: number;
name: string;
}
ReadonlyArray<T> / readonly T[] прибирають з типу методи, що змінюють масив (push, sort, splice).
Що варто знати:
- це лише перевірка компілятора. Під час виконання об'єкт звичайний - змінити його можна через
as anyчи з JavaScript-коду. Для справжньої незмінності -Object.freeze; readonlyповерхневий у звичайних типах:readonly items: Item[]забороняє замінити масив, але не змінити його вміст. Для вкладених структур -readonlyна кожному рівні чи утиліта на кшталтDeepReadonly;- масив
readonly string[]не можна передати туди, де очікуютьstring[]- функції, що не змінюють масив, варто оголошувати зreadonlyв параметрі.
Постфіксний ! каже компілятору: «це значення точно не null і не undefined». Компілятор вірить на слово й прибирає null | undefined з типу.
const input = document.querySelector('#email')!; // HTMLElement замість HTMLElement | null
const price = prices.get('coffee')!; // number замість number | undefined
! нічого не перевіряє під час виконання. Якщо значення все ж null, помилка виникне пізніше й далеко від причини: Cannot read properties of null. TypeScript саме для того й попереджав.
Коли ! доречний:
- значення гарантовано існує з причин, яких компілятор не бачить, і ця гарантія очевидна поруч у коді;
- ініціалізація, яку TypeScript не відстежує (поле класу, яке заповнює фреймворк), - хоча для полів краще
field!: Typeу оголошенні з коментарем, ніж!при кожному використанні.
Краще альтернативи в більшості випадків:
- явна перевірка з помилкою - падіння одразу з зрозумілим текстом:
const input = document.querySelector<HTMLInputElement>('#email');
if (!input) throw new Error('Поле #email не знайдено');
input.value; // тут уже HTMLInputElement
- функція-помічник:
function assertDefined<T>(value: T, message: string): asserts value is NonNullable<T> {
if (value == null) throw new Error(message);
}
- опціональний ланцюжок і значення за замовчуванням, якщо відсутність нормальна:
prices.get('coffee') ?? 0; - переписати код так, щоб тип був точним: зберігати знайдений елемент у змінній після перевірки, а не шукати двічі.
Типові місця, де ! приховує баги:
map.get(key)!- ключа може не бути;array.find(...)!- елемента може не знайтися;useRef<HTMLDivElement>(null).current!в ефекті, що може виконатися до монтування;process.env.API_KEY!- змінну оточення можуть не задати.
Лінтер (@typescript-eslint/no-non-null-assertion) змушує обґрунтовувати кожен ! - корисне правило для команди.
Важливо: strict у TypeScript 6+ увімкнено за замовчуванням, тож перевірки на null діють навіть без явного "strict": true - і ! стає помітнішою «дірою» в цих перевірках.
value as T - твердження типу: розробник каже компілятору «вважай це значення типом T». Нічого не перетворюється й не перевіряється під час виконання.
const user = (await response.json()) as User;
user.email.toLowerCase(); // впаде, якщо API повернув { error: '...' }
Компілятор довіряє, а реальні дані можуть бути іншими. Помилка переноситься з місця, де дані прийшли, у випадкове місце далі в коді.
Що as дозволяє, а що ні:
- звужувати й розширювати в межах сумісних типів (
unknown→User,HTMLElement→HTMLInputElement); - приведення між несумісними типами (
'текст' as number) - помилка компіляції. Але подвійнеas unknown as Tобходить і це - верна ознака, що щось не так.
Альтернативи:
1. Перевірка під час виконання для зовнішніх даних (API, localStorage, форми):
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), email: z.email() });
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(await response.json()); // кидає помилку, якщо дані не ті
2. Користувацький type guard:
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'email' in value;
}
3. Звуження вбудованими перевірками: instanceof, typeof, in, перевірка поля-дискримінатора.
4. Типізоване API замість приведення: document.querySelector<HTMLInputElement>('input[name=email]') замість as HTMLInputElement; генерік у useState<User | null>(null).
5. satisfies для об'єктів-літералів - перевірка форми без втрати точного типу.
Коли as допустимий:
as const- не приведення, а звуження до літеральних типів;- тести й моки (
{} as Partial<Service> as Service) - з розумінням ризику; - місця, де ви знаєте більше за компілятор і це очевидно з контексту (наприклад, після власної перевірки, яку TypeScript не розпізнав).
Правило лінтера @typescript-eslint/consistent-type-assertions дає змогу заборонити as для об'єктних літералів і змусити використовувати анотації чи satisfies.
TypeScript перевіряє типи структурно: об'єкт підходить до типу, якщо має всі потрібні властивості потрібних типів. Зайві властивості цьому не заважають.
type Point = { x: number; y: number };
const point3d = { x: 1, y: 2, z: 3 };
const p: Point = point3d; // ок - є x і y, z просто ігнорується
Але для «свіжого» об'єкта-літерала діє додаткова перевірка зайвих властивостей:
const p: Point = { x: 1, y: 2, z: 3 };
// помилка: Object literal may only specify known properties, and 'z' does not exist in type 'Point'
Чому так. Якщо ви пишете літерал прямо там, де очікується конкретний тип, зайва властивість майже напевно - друкарська помилка або непорозуміння:
createUser({ name: 'Оля', emial: 'olia@example.com' }); // emial замість email - зловлено
Але якщо об'єкт уже існує й має ширшу форму, передати його туди, де потрібна частина полів, - нормальна практика структурної типізації.
Де перевірка зайвих властивостей не спрацьовує:
- об'єкт спершу присвоєно змінній, а потім передано;
- результат функції, розгортання (
{ ...defaults, extra: 1 }перевіряється, а от{ ...obj }, деobjмає зайве, - ні); - тип має індексну сигнатуру (
[key: string]: unknown) - тоді будь-які ключі допустимі; - приведення через
as.
Пастка з опціональними полями:
type Options = { timeout?: number; retries?: number };
const userOptions = { timeout: 500, retires: 3 }; // друкарська помилка в retries
const options: Options = userOptions; // жодної помилки! усі поля опціональні
Тип, у якого всі поля необов'язкові («слабкий тип»), TypeScript частково захищає: якщо в об'єкта немає жодного спільного поля з типом ({ timout: 500 }), буде помилка. Але одна правильна властивість поруч з друкарською помилкою - і перевірка мовчить, а retries тихо лишається не заданим.
Що робити: передавати літерали напряму в місця з типом, використовувати satisfies для об'єктів-конфігурацій, а для зовнішніх даних - валідацію під час виконання (Zod), яка може відкидати невідомі ключі (.strict()).
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 типові тести нічого не ловлять.
Наївна шина подій - 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) пришвидшив перевірку в рази, але це не скасовує проблему: складні типи все одно важко читати й підтримувати, а помилки в них - розуміти.
Тест на доречність: чи стане коду помітно безпечніше від цього типу, і чи зрозуміє його колега без вашої допомоги? Якщо на обидва питання відповідь «ні» - простіший тип кращий.