TypeScript: питання на співбесіді рівня Senior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
29 питань
Utility types - вбудовані перетворення типів, щоб не описувати схожі форми вручну:
interface User {
id: number;
name: string;
email: string;
password: string;
}
type UserUpdate = Partial<Omit<User, 'id'>>; // усі поля, крім id, необов'язкові
type PublicUser = Omit<User, 'password'>;
type UserPreview = Pick<User, 'id' | 'name'>;
type RolesMap = Record<'admin' | 'editor', string[]>;
type Frozen = Readonly<User>;
Ще корисні: Required, NonNullable, ReturnType<typeof fn>, Parameters<typeof fn>, Awaited<Promise<T>>.
Під капотом це mapped types - тип, що проходить по ключах іншого типу:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Модифікатори можна і додавати, і знімати
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Перейменування ключів через as (TS 4.1+)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<Pick<User, 'name'>>; // { getName: () => string }
Навіщо на практиці: один джерельний тип (User), а похідні - форма оновлення, публічне представлення, стан форми - виводяться з нього. Додали поле в User - усі похідні оновилися, і компілятор покаже місця, які треба доробити.
Обережно: надто «розумні» типи важко читати й налагоджувати, а повідомлення про помилки в них стають незрозумілими. Якщо тип потрібно пояснювати, інколи простіше описати його явно.
Discriminated union - об'єднання об'єктних типів зі спільним полем-літералом («дискримінантом»). Перевірка цього поля звужує тип до конкретного варіанта.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function render(state: RequestState) {
switch (state.status) {
case 'idle':
return 'Натисніть «Завантажити»';
case 'loading':
return 'Завантаження...';
case 'success':
return `${state.data.length} користувачів`; // data доступне лише тут
case 'error':
return `Помилка: ${state.error}`;
}
}
Чому це краще за набір необов'язкових полів ({ loading?: boolean; data?: User[]; error?: string }): неможливі стани (одночасно data і error, loading і data) стають непредставлюваними на рівні типів.
Перевірка вичерпності: якщо додати новий варіант, компілятор має вказати всі місця, де його не обробили:
function assertNever(value: never): never {
throw new Error(`Необроблений стан: ${JSON.stringify(value)}`);
}
switch (state.status) {
// ...усі case
default:
return assertNever(state);
}
У default після всіх case тип state звузився до never. Додали { status: 'cancelled' } - тепер у default потрапляє цей варіант, і передати його в параметр типу never не можна: помилка компіляції саме там, де треба доробити.
Де застосовують: стани запитів і форм, події та повідомлення (Redux actions, WebSocket-повідомлення), результати операцій ({ ok: true; value } | { ok: false; error }).
Розширення (widening) - TypeScript виводить для змінної ширший тип, ніж конкретне значення, якщо змінна може змінитися.
const a = 'draft'; // тип 'draft' - константа ніколи не зміниться
let b = 'draft'; // тип string - змінна може отримати інше значення
Об'єкти й масиви розширюються навіть у const, бо їх вміст змінюваний:
const post = { status: 'draft' }; // { status: string }
post.status = 'anything'; // дозволено
function publish(status: 'draft' | 'published') {}
publish(post.status); // помилка: string не 'draft' | 'published'
Способи зберегти вузький тип:
const post = { status: 'draft' as const }; // { status: 'draft' }
const post2 = { status: 'draft' } as const; // { readonly status: 'draft' }
const post3: { status: 'draft' | 'published' } = { status: 'draft' };
const post4 = { status: 'draft' } satisfies { status: 'draft' | 'published' };
as const- найвужчі літеральні типи йreadonlyна всіх рівнях;- анотація - тип визначає анотація (вона ж і обмежує);
satisfies- перевіряє відповідність, але зберігає виведений тип значення.
Контекстна типізація - виведення «ззовні»: якщо тип очікуваного значення відомий, літерал не розширюється:
type Options = { method: 'GET' | 'POST' };
const options: Options = { method: 'GET' }; // ок
fetchJson({ method: 'GET' }); // ок, якщо параметр типізовано як Options
Також параметри колбеків отримують типи з контексту: items.map((item) => ...).
«Найкращий загальний тип» для масивів - об'єднання типів елементів: [1, 'a'] - (string | number)[], а не кортеж.
Константні параметри типу (TS 5.0) - function f<const T>(x: T) змушує виводити T так, ніби аргумент записано з as const, без вимоги до викликача.
Типова помилка в коді:
const config = { mode: 'production' }; // mode: string
createApp(config); // помилка: очікується 'production' | 'development'
Рішення - satisfies AppConfig чи анотація типу при оголошенні, а не as при виклику: as вимикає перевірку значення.
Звуження після перевірок - зворотний процес: let x: string | number, а після typeof x === 'string' у гілці - string.
Чотири типи, які легко сплутати, і вони приймають різні множини значень.
| Тип | Що приймає |
|---|---|
unknown |
будь-що, включно з null і undefined |
{} |
будь-що, крім null і undefined (і рядки, і числа!) |
object |
лише не-примітиви: об'єкти, масиви, функції |
Object |
майже як {} (будь-що з методами Object.prototype), застарілий запис |
const a: {} = 5; // ок - число не null і не undefined
const b: {} = 'текст'; // ок
const c: {} = null; // помилка
const d: object = { }; // ок
const e: object = []; // ок
const f: object = 5; // помилка - примітив
Пастка {}. Його часто пишуть, маючи на увазі «порожній об'єкт» чи «якийсь об'єкт», а отримують «будь-яке ненульове значення». Правило лінтера @typescript-eslint/no-empty-object-type попереджає саме про це.
Що використовувати насправді:
- «будь-яке значення, перевірю пізніше» -
unknown; - «будь-який об'єкт, ключі невідомі» -
Record<string, unknown>. Доступ до поля даєunknown, і код змушений перевіряти; - «не-примітив» (наприклад, для
WeakMap-ключів чи функцій, що працюють з посиланнями) -object; - «справді порожній об'єкт, без полів» -
Record<PropertyKey, never>; - «будь-що, крім null/undefined» у узагальненнях - обмеження
T extends {}, наприклад у власномуNonNullable.
{} в узагальненнях - корисний інструмент: NonNullable<T> визначено як T & {} - перетин прибирає null і undefined з об'єднання.
Object з великої літери - тип обгортки, що лишився з ранніх версій. Документація радить ніколи його не використовувати.
Чому object не дає доступу до полів:
function print(value: object) {
value.name; // помилка: у object немає властивості name
}
object лише гарантує, що це не примітив. Щоб читати поля, тип має їх описувати або бути Record<string, unknown> з подальшою перевіркою.
У TypeScript є два окремих «простори імен»:
- простір значень - те, що існує під час виконання: змінні, функції, класи, об'єкти;
- простір типів - те, що існує лише для компілятора і зникає в JavaScript:
type,interface.
Одне ім'я може одночасно бути і значенням, і типом - і це різні сутності:
const Status = { Draft: 'draft', Published: 'published' } as const; // значення
type Status = (typeof Status)[keyof typeof Status]; // тип з тим самим іменем
function setStatus(status: Status) {} // тут Status - тип
setStatus(Status.Draft); // тут Status - значення
Що належить до обох просторів одразу:
- клас - і конструктор (значення), і тип екземпляра:
class User { constructor(public name: string) {} }
const u: User = new User('Оля'); // User - тип екземпляра і значення-конструктор
type UserCtor = typeof User; // тип самого класу (конструктора)
enum- об'єкт під час виконання і тип його значень;- простори імен (
namespace) з кодом.
Переходи між просторами:
typeof значення- отримати тип зі значення:typeof config,typeof fetchUser;ReturnType<typeof fn>,InstanceType<typeof User>- похідні типи;- зворотного переходу немає: тип не може стати значенням. Неможливо пройтися циклом по ключах
interfaceпід час виконання, перевіритиinstanceofдляtypeчи отримати список значень з об'єднання літералів.
Практичні наслідки:
- джерело правди - значення, тип з нього виводять. Масив
['draft', 'published'] as constдає і список для<select>, і тип(typeof STATUSES)[number]. Навпаки - з типу список не отримати; - схеми валідації (Zod) - те саме: схема - значення, що існує під час виконання, а тип виводиться з неї (
z.infer<typeof schema>); import typeімпортує лише з простору типів - такий імпорт гарантовано зникає після компіляції. ЗverbatimModuleSyntaxкомпілятор вимагає позначати імпорти лише типів явно;- помилка «X only refers to a type, but is being used as a value here» - спроба використати тип як значення (
instanceof Interface,Object.keys(SomeType)).
Тип може посилатися сам на себе - так описують деревоподібні структури.
Значення JSON:
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
const data: Json = { users: [{ id: 1, tags: ['php'], manager: null }] };
const bad: Json = { createdAt: new Date() }; // помилка: Date не є Json
Корисно для функцій, що приймають «будь-який серіалізовний об'єкт» (кеш, localStorage, postMessage) - тоді Date, Map, функції не пройдуть.
Глибокі утиліти:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Settings = { theme: { colors: { primary: string; accent: string } } };
const patch: DeepPartial<Settings> = { theme: { colors: { accent: '#f00' } } };
Деревоподібні дані:
type Category = { id: number; name: string; children: Category[] };
type MenuItem = { label: string; href?: string; items?: MenuItem[] };
Обмеження й пастки:
- функції, дати,
Map-T[K] extends objectістинне і для них.DeepReadonly<Date>спробує обійти методиDate. Їх виключають окремими гілками:T[K] extends Function | Date | Map<any, any> ? T[K] : ...; - масиви в mapped types обробляються як масиви (гомоморфні типи), але кортежі й readonly-масиви інколи потребують окремої обробки;
- глибина інстанціювання: компілятор обмежує глибину рекурсії. Для дуже глибоких чи «вибухових» типів з'являється «Type instantiation is excessively deep and possibly infinite». Хвостова рекурсія в умовних типах (TS 4.5+) дозволяє до ~1000 рівнів, а звичайна обривається значно раніше - на кількох десятках;
- продуктивність: рекурсивні типи над великими структурами (типи відповідей API на сотні полів) помітно сповільнюють редактор. Повільна підказка в IDE - ознака, що тип надто «розумний»;
- рекурсивні псевдоніми (
type Json = ... Json[]) підтримуються з TS 3.7; раніше доводилося обходити через інтерфейси.
Порада: рекурсивні типи - для справді рекурсивних даних. Якщо рекурсія потрібна лише для «розумної» перевірки, варто зважити, чи вартий виграш у типах повільнішого редактора й незрозумілих повідомлень про помилки.
Шаблонні рядкові типи разом з mapped types дають змогу генерувати API з опису даних - і компілятор перевіряє назви, які раніше були «просто рядками».
Обробники змін для кожного поля стану:
type State = { name: string; age: number; subscribed: boolean };
type ChangeHandlers<T> = {
[K in keyof T & string as `on${Capitalize<K>}Change`]: (value: T[K]) => void;
};
type Handlers = ChangeHandlers<State>;
// {
// onNameChange: (value: string) => void;
// onAgeChange: (value: number) => void;
// onSubscribedChange: (value: boolean) => void;
// }
Типізований емітер подій:
type Events = {
'order:created': { orderId: number };
'order:paid': { orderId: number; amount: number };
'user:logout': undefined;
};
class Emitter<E extends Record<string, unknown>> {
on<K extends keyof E & string>(event: K, handler: (payload: E[K]) => void) { /* ... */ }
emit<K extends keyof E & string>(event: K, payload: E[K]) { /* ... */ }
}
const bus = new Emitter<Events>();
bus.on('order:paid', ({ amount }) => {}); // amount: number
bus.emit('order:payd', { orderId: 1 }); // помилка: друкарська помилка в назві
Вибір підмножини подій за префіксом:
type OrderEvents = Extract<keyof Events, `order:${string}`>; // 'order:created' | 'order:paid'
Ключі перекладів з вкладеного об'єкта:
type Paths<T> = {
[K in keyof T & string]: T[K] extends object ? `${K}.${Paths<T[K]>}` : K;
}[keyof T & string];
const uk = { auth: { login: 'Увійти', logout: 'Вийти' }, nav: { home: 'Головна' } };
type Key = Paths<typeof uk>; // 'auth.login' | 'auth.logout' | 'nav.home'
declare function t(key: Key): string;
t('auth.login'); // ок
t('auth.lgoin'); // помилка
Прийом { ... }[keyof T] - обчислити mapped type і взяти об'єднання всіх значень.
Де межа корисності:
- розмір об'єднань: сотні ключів перекладів на кілька рівнів вкладеності - сповільнення компілятора й редактора. Бібліотеки i18n часто генерують такі типи окремим кроком збирання;
- повідомлення про помилки для складних типів бувають нечитабельними - варто мати прості іменовані проміжні типи;
- динамічні ключі (зібрані з даних під час виконання) не перевіряються - знадобиться приведення типу чи валідація.
Де це приносить найбільшу користь: ключі перекладів, назви подій і каналів, маршрути, CSS-змінні дизайн-системи - рядки, помилки в яких інакше знаходяться лише під час виконання.
Варіантність описує, як підтипи узагальненого типу пов'язані з підтипами його параметра. Нехай Dog - підтип Animal.
- Коваріантність (як у «виробника» значень):
Producer<Dog>можна використати там, де очікуєтьсяProducer<Animal>. ПоверненийDog- тежAnimal; - контраваріантність (як у «споживача»): навпаки,
Consumer<Animal>підходить замістьConsumer<Dog>. Функція, що вміє обробити будь-яку тварину, обробить і собаку; - інваріантність - коли тип і читається, і записується: жоден з варіантів не безпечний.
Параметри функцій - контраваріантні (з strictFunctionTypes, що входить у strict):
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
type Handler = (animal: Animal) => void;
const dogHandler = (dog: Dog) => dog.bark();
const h: Handler = dogHandler; // помилка: обробнику передадуть кота, а він викличе bark()
Але методи - біваріантні, і це свідома діра в системі типів:
interface WithMethod { handle(animal: Animal): void } // синтаксис методу
interface WithProperty { handle: (animal: Animal) => void } // властивість-функція
const m: WithMethod = { handle: dogHandler }; // компілюється!
const p: WithProperty = { handle: dogHandler }; // помилка
strictFunctionTypes перевіряє лише типи, оголошені як функції-властивості. Методи лишено біваріантними для сумісності з величезною кількістю наявного коду (наприклад, Array<T>.push(item: T) - метод, і без біваріантності Dog[] не був би сумісний з Animal[]).
Масиви коваріантні - і через це несоундні:
const dogs: Dog[] = [];
const animals: Animal[] = dogs; // дозволено
animals.push({ name: 'Мурка' }); // кіт потрапив у масив собак
dogs[0].bark(); // падіння під час виконання
readonly Animal[] прибирає проблему: в нього не можна записувати.
Анотації варіантності (TS 4.7+) - явно вказати варіантність параметра:
interface Producer<out T> { get(): T }
interface Consumer<in T> { accept: (value: T) => void }
interface State<in out T> { get(): T; set: (v: T) => void }
Вони прискорюють перевірку великих узагальнених типів і перевіряють, що оголошення відповідає заявленій варіантності. Але через біваріантність методів анотація out T поряд з методом set(v: T) помилки не дасть - лише з властивістю-функцією.
Практичні висновки: у власних інтерфейсах для колбеків надавати перевагу синтаксису властивості (onChange: (v: T) => void) - отримаєте строгішу перевірку; для параметрів, які функція лише читає, - readonly масиви.
За замовчуванням TypeScript виводить параметри типу розширено: літерали стають string, масиви - звичайними масивами.
function routes<T extends readonly string[]>(paths: T): T {
return paths;
}
const r = routes(['/home', '/about']); // string[] - назви маршрутів втрачено
Раніше викликач мусив писати as const на кожному виклику: routes(['/home', '/about'] as const).
Константний параметр типу (TS 5.0) переносить цю вимогу в оголошення функції:
function routes<const T extends readonly string[]>(paths: T): T {
return paths;
}
const r = routes(['/home', '/about']); // readonly ['/home', '/about']
type Route = (typeof r)[number]; // '/home' | '/about'
Аргумент виводиться так, ніби його записано з as const.
Важлива деталь - readonly в обмеженні. Якщо обмеження - змінюваний масив (T extends string[]), const не допоможе: readonly-кортеж не можна присвоїти string[], і TypeScript відкотиться до string[]. Тому обмеження пишуть як readonly unknown[].
Де корисно:
- конфігурації й реєстри: маршрути, ключі подій, назви полів форм, налаштування таблиць - тип будується з літералів, переданих викликачем;
- builder-API:
defineConfig({ ... }),createRoutes([...]),createStore({ ... })- бібліотеки отримують точні типи безas constу користувача.
Інші способи керувати виведенням:
NoInfer<T>(TS 5.4) - заборонити виводитиTз певного аргументу, щоб той лише перевірявся;- обмеження з літералами:
T extends 'sm' | 'md' | 'lg'- тодіTвиводиться як конкретний літерал, а неstring; - порядок аргументів: TypeScript виводить параметри зліва направо, і колбеки з неанотованими параметрами отримують типи від попередніх аргументів. Тому функції на кшталт
useQuery(key, fetcher)чиpipe(value, ...fns)проєктують так, щоб «джерело» типу йшло першим; - явні аргументи типу
fn<User>(...)- останній варіант: TypeScript не підтримує часткового виведення, тож явно доведеться вказати всі параметри без значень за замовчуванням.
Пастка: const не робить значення незмінним під час виконання - лише впливає на виведений тип (з readonly в ньому).
Докладніше в документації: TypeScript 5.0: const-параметри типу
У TypeScript дві несумісні реалізації декораторів.
Стандартні декоратори (TypeScript 5.0+, відповідають пропозиції TC39) - працюють без прапорців:
function logged<This, Args extends unknown[], R>(
target: (this: This, ...args: Args) => R,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>,
) {
return function (this: This, ...args: Args): R {
console.log(`виклик ${String(context.name)}`);
return target.call(this, ...args);
};
}
class OrderService {
@logged
place(id: number) { /* ... */ }
}
Декоратор отримує значення (метод, поле, клас) і об'єкт context: kind, name, static, private, addInitializer(), access, metadata. Повертає заміну чи нічого.
Ключове слово accessor - поле з автоматичними гетером і сетером, які декоратор може перехопити (основа реактивних полів у Lit, MobX):
class Counter {
@observable accessor count = 0;
}
Експериментальні декоратори (experimentalDecorators: true) - стара реалізація ранньої пропозиції. Сигнатура інша: (target, propertyKey, descriptor). На них побудовані Angular, NestJS, TypeORM, class-validator, Inversify.
function old(target: any, key: string, descriptor: PropertyDescriptor) {}
Без прапорця такий декоратор не скомпілюється: TypeScript перевіряє його як стандартний і повідомляє, що під час виконання він отримає 2 аргументи, а очікує 3.
Відмінності, що мають значення:
- декоратори параметрів (
constructor(@Inject() service: X)) є лише в експериментальних - стандарт їх не має. Саме на них тримається впровадження залежностей у NestJS і Angular; emitDecoratorMetadata(метадані типів для DI,reflect-metadata) - лише з експериментальними;- метадані в стандарті -
context.metadataіSymbol.metadata, без автоматичних типів параметрів; - порядок виконання й семантика ініціалізації полів відрізняються.
Що обирати:
- фреймворк вимагає експериментальних (Angular, NestJS) - лишатися на них, доки фреймворк не перейде;
- новий код без таких фреймворків - стандартні декоратори: вони стануть частиною JavaScript і не залежать від прапорця компілятора.
Стан стандарту: пропозиція на стадії 3; рушії браузерів і Node.js нативно декоратори ще не виконують - TypeScript компілює їх у допоміжний код (__esDecorate). Тому декоратори несумісні з erasableSyntaxOnly-підходом «просто стерти типи».
Поля класу в TypeScript з'явилися раніше, ніж стандартні поля класів у JavaScript, і TypeScript довго компілював їх як присвоєння в конструкторі. Стандарт же визначає поля через Object.defineProperty - з іншою семантикою.
useDefineForClassFields перемикає на стандартну семантику. Він увімкнений за замовчуванням, коли target - ES2022 чи новіший (а з TypeScript 6 типова ціль - поточна версія ECMAScript, тож для нових проєктів це майже завжди так).
Де різниця стає помітною:
1. Поле без ініціалізатора в нащадку затирає значення з батька:
class Base {
name: string;
constructor() {
this.name = 'base';
this.init();
}
init() {}
}
class Child extends Base {
name!: string; // лише уточнення типу
}
new Child().name; // зі стандартною семантикою - undefined!
Оголошене в нащадку поле визначається заново (зі значенням undefined) після батьківського конструктора. Рішення - declare name: string;: це оголошення лише для типів, без поля під час виконання.
Компілятор попереджає про обидва випадки нижче: «Property 'name' will overwrite the base property... add a 'declare' modifier» (TS2612) і «'value' is defined as an accessor in class..., but is overridden here as an instance property» (TS2610). Ці помилки не варто глушити - вони описують реальну зміну поведінки.
2. Поля перекривають сетери батька:
class Base {
set value(v: number) { console.log('setter', v); }
}
class Child extends Base {
value = 1; // стандарт: власна властивість, сетер не викликається
}
3. Порядок ініціалізації з parameter properties: поля ініціалізуються до тіла конструктора, і поле, що використовує parameter property, може побачити undefined.
4. Бібліотеки з декораторами чи реактивністю (старі MobX, деякі ORM), що покладалися на присвоєння й сетери прототипу, ламаються зі стандартною семантикою.
Що робити:
- у новому коді - лишати стандартну семантику (так поводиться і браузер, і Node.js, і будь-який інший транспілятор);
- для перевизначення типу поля в нащадку -
declare, а не повторне оголошення; - при міграції старого коду - перевірити класи з успадкуванням полів і сетерів; тимчасово можна вимкнути прапорець, але це розходження з реальним JavaScript.
Чому це не «дрібниця налаштувань»: код, що працює однаково в тестах (одна ціль компіляції) і по-різному в продакшені (інша), - одна з найнеочевидніших помилок при зміні target.
Сучасні Node.js (з 22.18 і 23.6) запускають .ts файли напряму: перед виконанням вони просто стирають анотації типів, не перевіряючи їх і не компілюючи. Так само працюють швидкі інструменти на кшталт Bun, Deno та трансформацій у збирачах.
Що можна стерти - більша частина TypeScript: анотації, інтерфейси, type, generics, as, satisfies, import type. Після видалення лишається коректний JavaScript.
Що стерти неможливо - синтаксис, якому потрібна генерація коду:
enum- створює об'єкт під час виконання;namespaceз кодом усередині;- parameter properties (
constructor(private name: string)) - генерують присвоєння; import x = require('...'),export =;- старі декоратори з
emitDecoratorMetadata.
Прапорець erasableSyntaxOnly (TypeScript 5.8+) робить такий синтаксис помилкою компіляції - код гарантовано запуститься через стирання типів:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noEmit": true
}
}
verbatimModuleSyntax доповнює його: імпорти лише типів мають бути явно позначені import type, щоб стирання не лишило імпорт неіснуючого значення.
Замінники:
// замість enum
const Role = { Admin: 'admin', Editor: 'editor' } as const;
type Role = (typeof Role)[keyof typeof Role];
// замість parameter properties
class User {
readonly id: number;
constructor(id: number) { this.id = id; }
}
Як це змінює роль tsc: компілятор дедалі частіше виконує лише перевірку типів (noEmit), а виконання й збирання роблять інші інструменти. TypeScript 7 (нативний компілятор на Go) робить таку перевірку в рази швидшою - і поділ «типи перевіряє tsc, код запускає рушій» стає типовою схемою.
Обмеження стирання типів у Node.js:
- типи не перевіряються під час запуску - помилки типів знайде лише
tsc; tsconfigігнорується -paths,baseUrl(у TypeScript 7 його вже немає) не працюють;- імпорти з розширенням
.ts- потрібноallowImportingTsExtensionsуtsconfigабо перезапис розширень (rewriteRelativeImportExtensions) при компіляції бібліотеки.
Висновок: для коду, який може запускатися без збирання (скрипти, сервіси на Node.js), «стираний» TypeScript - розумний стандарт. Для фронтенду через збирач обмеження м'якші, але й там enum і parameter properties дедалі частіше замінюють.
Докладніше в документації: TypeScript 5.8: erasableSyntaxOnly
Аксесори (гетер і сетер) у класах і об'єктах виглядають ззовні як звичайна властивість, але виконують код при читанні й записі.
class Temperature {
#celsius = 0;
get fahrenheit(): number {
return this.#celsius * 1.8 + 32;
}
set fahrenheit(value: number) {
this.#celsius = (value - 32) / 1.8;
}
}
Правила TypeScript:
- властивість лише з гетером автоматично
readonly- запис дає помилку; - з TypeScript 5.1 гетер і сетер можуть мати непов'язані типи (раніше тип гетера мусив бути сумісним з типом сетера).
Навіщо різні типи - сетер приймає ширше, гетер повертає нормалізоване:
class Field {
#value = '';
get value(): string {
return this.#value;
}
set value(input: string | number | null) {
this.#value = input === null ? '' : String(input);
}
}
const field = new Field();
field.value = 42; // дозволено
field.value.toUpperCase(); // завжди string
Так влаштовано чимало API браузера: властивість приймає ширший набір значень (рядок, null), а повертає нормалізований рядок. Опис таких API в .d.ts став точнішим саме завдяки цій можливості.
Те саме в інтерфейсах і типах об'єктів:
interface Thing {
get size(): number;
set size(value: number | string | boolean);
}
Ключове слово accessor (стандартні декоратори) - коротший запис поля з автоматичними гетером і сетером над прихованим сховищем: accessor count = 0. Корисне разом із декораторами, що перехоплюють доступ.
Що варто пам'ятати:
- дорогі гетери виглядають як дешеві поля - виклик у циклі може приховано коштувати багато;
- побічні ефекти в гетері - погана практика: читання не повинно змінювати стан;
- серіалізація:
JSON.stringifyне бачить гетерів класу (вони в прототипі) - потрібенtoJSON(); - поля проти аксесорів у нащадках: з
useDefineForClassFieldsполе в нащадку перекриває сетер батька - сетер не викликається.
Докладніше в документації: TypeScript 5.1: різні типи гетера й сетера
Triple-slash директиви - коментарі з трьома скісними рисками й XML-тегом, які TypeScript читає як інструкції компілятору:
/// <reference types="vite/client" />
/// <reference path="./legacy-globals.d.ts" />
/// <reference lib="es2025.collection" />
Види:
types="..."- підключити декларації пакета (як елемент масивуtypesуtsconfig, але для конкретного файлу);path="..."- включити інший файл у компіляцію;lib="..."- підключити вбудовану бібліотеку типів (наприклад, нові можливості ECMAScript) без зміниlibуtsconfig.
Головне правило - лише на початку файлу. Директива діє, тільки якщо перед нею немає нічого, крім коментарів і інших директив. Після першого виразу чи імпорту це звичайний коментар - без попереджень:
import { a } from './a.js';
/// <reference types="node" /> // ігнорується мовчки
Де вони ще доречні:
vite-env.d.tsу проєктах Vite -/// <reference types="vite/client" />: підключає типиimport.meta.envі імпорту ресурсів;- файли
.d.ts, що публікуються в пакеті, - щоб декларації явно залежали від іншого пакета типів (/// <reference types="node" />), коли без глобальних типів Node.js вони не мають сенсу; - окремі файли з особливими потребами - тестовий файл, якому потрібні глобальні типи фреймворку, коли решті проєкту вони не потрібні.
Де вони застаріли:
pathдля зв'язку модулів - ES-імпорти роблять це природно;pathлишився для старого коду без модулів;/// <reference no-default-lib="true"/>у TypeScript 6 перестала підтримуватися (замість неї -noLibчиlibReplacement);/// <amd-module />втратила сенс разом із видаленнямmodule: amdу TypeScript 6/7.
Порівняно з tsconfig: налаштування в tsconfig.json (types, lib, include) діють на весь проєкт і видні одразу. Директиви «ховають» залежності в окремих файлах. Тому в застосунках перевага за tsconfig, а директиви - для .d.ts і особливих файлів.
Пов'язана зміна TypeScript 6/7: через порожній за замовчуванням types директива /// <reference types="..." /> знову стала корисним способом підключити глобальні типи для окремого файлу.
Простори імен (namespaces) з'явилися в TypeScript до того, як JavaScript отримав ES-модулі. Вони групують код в іменований об'єкт у глобальній області:
namespace Validation {
export function isEmail(value: string): boolean {
return value.includes('@');
}
}
Validation.isEmail('a@b.ua');
Компілюються в IIFE, що створює об'єкт Validation.
ES-модулі - стандарт JavaScript: файл = модуль, явні import/export, власна область видимості, підтримка браузерами, Node.js і збирачами.
Чому для коду застосунку - модулі, а не namespaces:
- збирачі розуміють залежності між модулями - tree shaking, розділення коду. Простір імен - один об'єкт, з якого нічого не викинеш;
- явні залежності видно з імпортів; простори імен покладаються на порядок підключення файлів;
- стирання типів (Node.js,
erasableSyntaxOnly) не підтримує namespaces з виконуваним кодом - їм потрібна генерація; outFile, що склеював простори імен з багатьох файлів в один, видалено в TypeScript 6.
Де namespaces лишилися доречними:
- у файлах декларацій - для опису бібліотек, що створюють глобальні об'єкти, і для поєднання функції з властивостями (злиття з функцією чи класом);
- простори імен лише з типами (
namespace API { export type User = ... }) - стираються без генерації коду, хоча й тут частіше обходяться модулями; - доповнення глобальних просторів:
declare global { namespace Express { ... } },namespace NodeJS.
Ключове слово module для просторів імен: колись простір імен можна було оголосити як module Foo { }. Згодом з'явилося namespace, а module лише не радили. У TypeScript 6 це стало помилкою з можливістю тимчасово приглушити її, у TypeScript 7 - остаточною помилкою:
module Legacy { } // помилка: використовуйте namespace
namespace Modern { } // правильно
declare module 'some-lib' { } // це інше - ambient-оголошення модуля, і воно підтримується
Причина - можлива пропозиція «module blocks» до стандарту JavaScript, з якою старий синтаксис TypeScript конфліктував би.
Міграція старого коду з просторами імен на модулі зазвичай механічна: кожен простір імен - окремий файл з export, звернення Validation.isEmail - імпорт import { isEmail } from './validation.js' або import * as Validation from ....
Питання рівня Senior з реальних технічних співбесід - 29 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії