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

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

Обережно: надто «розумні» типи важко читати й налагоджувати, а повідомлення про помилки в них стають незрозумілими. Якщо тип потрібно пояснювати, інколи простіше описати його явно.

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

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 }).

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

Розширення (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> з подальшою перевіркою.

Докладніше в документації: Тип object

У 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)).

Докладніше в документації: Оператор typeof для типів

Тип може посилатися сам на себе - так описують деревоподібні структури.

Значення 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 5.0: декоратори

Поля класу в 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.

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

Сучасні 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="..." /> знову стала корисним способом підключити глобальні типи для окремого файлу.

Докладніше в документації: Triple-slash директиви

Простори імен (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 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Junior 36 Middle 35

Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії