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

Питання на співбесіді з TypeScript

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

100 питань

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

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

Пакет на TypeScript публікує скомпільований JavaScript і файли декларацій .d.ts (з declaration: true). Як їх знайде споживач - вирішує package.json.

Сучасний варіант - поле exports з умовою types:

{
  "name": "@acme/money",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./format": {
      "types": "./dist/format.d.ts",
      "import": "./dist/format.js"
    }
  },
  "files": ["dist"]
}

Правила, які найчастіше порушують:

  • types - першою умовою в кожному об'єкті. Умови перевіряються по порядку, і TypeScript візьме першу підходящу. Якщо import стоїть раніше, компілятор може «знайти» JavaScript замість декларацій;
  • кожна точка входу в exports має власні types - верхньорівневе поле types для підшляхів не діє;
  • подвійний пакет (ESM + CommonJS) потребує окремих декларацій для кожного формату (index.d.ts і index.d.cts). Одні .d.ts на обидва формати TypeScript інтерпретує в одному режимі - і споживачі з іншим форматом отримують неправильні типи (класична проблема «masquerading as ESM/CJS»);
  • files має включати каталог з .d.ts, інакше їх не буде в опублікованому архіві.

Старе поле types/typings у корені - для споживачів зі старими налаштуваннями резолюції. Опції moduleResolution: node10 у TypeScript 6/7 вже немає, тож сучасні споживачі читають exports; поле в корені можна лишити як запасний варіант.

Залежності типів: якщо публічні декларації посилаються на типи іншого пакета (import type { Request } from 'express'), той пакет (@types/express) має бути в dependencies чи peerDependencies, а не в devDependencies - інакше у споживачів типи зламаються.

Перевірка перед публікацією:

  • @arethetypeswrong/cli - перевіряє, що типи правильно резолвляться для всіх режимів (node16 ESM/CJS, bundler);
  • publint - узгодженість exports, files і реальних файлів;
  • npm pack --dry-run - що саме потрапить в архів.

isolatedDeclarations спрощує генерацію декларацій сторонніми інструментами (і пришвидшує збирання), але вимагає явних типів на експортах.

Докладніше в документації: Публікація декларацій

Щоб згенерувати .d.ts для файлу, TypeScript зазвичай виводить типи експортованих функцій і змінних - а для цього потрібен повний аналіз програми з усіма залежностями. Генерувати декларації файл за файлом незалежно неможливо.

isolatedDeclarations (TypeScript 5.5+) вимагає, щоб тип кожного експорту можна було отримати з самого файлу, без виведення через інші модулі. На практиці - явні типи результатів і анотації там, де тип неочевидний:

// помилка з isolatedDeclarations: тип результату треба вивести
export function getTotal(items: Item[]) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

// ок
export function getTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

export const DEFAULT_PAGE = 1;              // ок: літерал очевидний
export const config = createConfig();       // помилка: потрібна анотація
export const config: AppConfig = createConfig();

Обмеження стосуються лише експортованого API - внутрішній код модуля пишеться як завжди.

Що це дає:

1. Генерація декларацій без перевірки типів. Будь-який інструмент (esbuild, SWC, oxc, Rolldown) може видати .d.ts чисто синтаксично - перетворенням одного файлу, в рази швидше за tsc. Збирання бібліотеки більше не чекає на повну перевірку типів.

2. Паралельне збирання монорепозиторіїв. Залежний пакет потребує лише .d.ts своїх залежностей. Якщо декларації генеруються синтаксично, пакети можна перевіряти паралельно, не чекаючи, доки повністю перевіриться ланцюжок залежностей. TypeScript 7 прямо згадує це: паралельне збирання проєктних посилань (--builders) обмежене графом залежностей - за винятком проєктів з isolatedDeclarations і синтаксичною генерацією декларацій.

3. Стабільніший публічний API. Явні типи на експортах означають, що зміна реалізації не змінить мовчки контракт - добра практика для бібліотек незалежно від швидкості.

Ціна: більше анотацій на межах модулів. Редактор пропонує швидке виправлення «додати явний тип», тож міграція переважно механічна.

Кому вмикати:

  • бібліотекам і пакетам монорепозиторію, що публікують .d.ts;
  • великим кодовим базам із проєктними посиланнями.

Для кінцевого застосунку, який нічого не публікує, користь менша - там основний виграш дає TypeScript 7 сам по собі.

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

TypeScript 7 - компілятор, переписаний з TypeScript на Go. Порт робили максимально близько до оригіналу, тож перевірка типів дає ті самі результати, а виграш - у швидкості: нативний код і багатопотоковість.

Що це дає на практиці:

  • повна збірка у 8-12 разів швидша: за даними команди TypeScript, перевірка кодової бази VS Code - 10,6 с замість 125,7 с;
  • редактор: проєкт відкривається й показує першу помилку за секунду-дві замість десятків секунд;
  • пам'ять - зазвичай менше, ніж у TypeScript 6;
  • новий режим --watch на основі файлового спостерігача з Parcel, портованого на Go.

Нові прапорці паралелізму:

  • --checkers N - кількість паралельних перевіряльників типів (за замовчуванням 4). Більше - швидше на потужних машинах, але більше пам'яті; на слабких CI-раннерах варто зменшити;
  • --builders N - паралельна збірка проєктів у --build (монорепозиторії). Множиться з --checkers: 4 × 4 - до 16 перевіряльників одночасно;
  • --singleThreaded - вимкнути паралелізм (налагодження, обмежені середовища).

Різна кількість --checkers у рідкісних випадках може дати різні результати, залежні від порядку, - тому команді варто зафіксувати одне значення для всіх середовищ.

Що потребує уваги при переході:

  • TypeScript 7 бере значення за замовчуванням з 6.0 (strict, types: [], rootDir: ".") і робить помилками все, що 6.0 оголосив застарілим: baseUrl, moduleResolution: node10, target: es5, module: amd/umd, esModuleInterop: false та інше. Рекомендований шлях - спершу перейти на 6.0 і прибрати всі попередження;
  • немає програмного API в 7.0 - його обіцяють у 7.1. Інструменти, що імпортують typescript як бібліотеку (typescript-eslint, частина плагінів збирачів), працюють через пакет сумісності @typescript/typescript6, встановлений поряд через псевдонім npm. Тоді tsc - це TypeScript 7, а інструменти бачать API 6.0;
  • JSDoc у JavaScript-файлах аналізується ближче до .ts - деякі старі шаблони перестали розпізнаватися;
  • шаблонні рядкові типи тепер працюють з кодовими точками Unicode, а не з половинками сурогатних пар.

Що лишилося незмінним: мова, синтаксис і правила перевірки. Код, що компілюється в 6.0 без попереджень, компілюється в 7.0 так само.

Докладніше в документації: Анонс TypeScript 7.0

Сучасний Node.js виконує .ts-файли без окремої збірки: він замінює анотації типів пробілами (type stripping) і запускає те, що лишилося.

node scripts/import-vacancies.ts

Зняття типів увімкнене за замовчуванням з Node.js 22.18 / 23.6 і стабільне з 24.12 / 25.2. Перевірки типів при цьому немає - помилки типів не зупинять запуск.

Обмеження - лише «стиранний» синтаксис. Node.js не генерує код, він тільки прибирає типи. Конструкції TypeScript, що створюють JavaScript, не підтримуються:

  • enum;
  • namespace з кодом під час виконання;
  • параметри-властивості в конструкторі (constructor(private repo: Repo));
  • import x = require(...) і псевдоніми імпорту;
  • декоратори (поки їх немає в самому JavaScript).

Прапорець --experimental-transform-types, що їх перетворював, у Node.js 26 прибрали.

erasableSyntaxOnly (з TypeScript 5.8) змушує tsc повідомляти про такі конструкції заздалегідь:

enum Status { Draft, Published }   // error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled

Замість enum - об'єкт з as const і тип-об'єднання; замість параметрів-властивостей - звичайні поля.

Рекомендовані Node.js налаштування tsconfig.json:

{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true
  }
}

Інші особливості:

  • розширення обов'язкові: import { x } from './utils.ts';
  • import type для типів - інакше Node.js залишить імпорт і впаде (тому verbatimModuleSyntax);
  • tsconfig.json ігнорується: paths не працюють - замість них subpath imports у package.json ("#/*");
  • файли в node_modules не виконуються - пакети мають публікуватися як JavaScript;
  • .tsx не підтримується.

Коли це доречно: скрипти, утиліти, інструменти розробки, невеликі сервіси - там, де збірка була зайвим кроком. Для повної підтримки TypeScript (paths, enum, JSX) - інструменти на кшталт tsx або звичайна збірка.

Докладніше в документації: Node.js: TypeScript

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 35 Senior 29

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