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

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

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

100 питань

Крім утиліт для об'єктів (Partial, Pick, Omit, Record), TypeScript має утиліти для функцій, промісів і керування виведенням типів.

Функції й класи:

async function fetchUser(id: number, withPosts = false) {
  return { id, name: 'Оля', posts: withPosts ? [] : undefined };
}

type Args = Parameters<typeof fetchUser>;          // [id: number, withPosts?: boolean]
type Result = ReturnType<typeof fetchUser>;        // Promise<{ ... }>
type UserData = Awaited<ReturnType<typeof fetchUser>>;   // { id: number; name: string; ... }

class Service { constructor(public url: string) {} }
type CtorArgs = ConstructorParameters<typeof Service>;   // [url: string]
type Instance = InstanceType<typeof Service>;            // Service

Awaited<T> рекурсивно розгортає проміси: Awaited<Promise<Promise<number>>> - number. Так само типізовано await і Promise.all.

Об'єднання:

  • Exclude<T, U> - прибрати з об'єднання;
  • Extract<T, U> - залишити лише відповідні;
  • NonNullable<T> - прибрати null і undefined.

NoInfer<T> (TS 5.4) - заборонити виводити параметр типу з цієї позиції:

function createSelect<T extends string>(options: T[], defaultValue: NoInfer<T>) {}

createSelect(['sm', 'md', 'lg'], 'md');   // ок
createSelect(['sm', 'md', 'lg'], 'xl');   // помилка

Без NoInfer TypeScript вивів би T з обох аргументів - як 'sm' | 'md' | 'lg' | 'xl' - і помилки не було б. NoInfer каже: тип визначають лише варіанти, а значення за замовчуванням має їм відповідати.

Рядки: Uppercase, Lowercase, Capitalize, Uncapitalize - для шаблонних рядкових типів.

ThisParameterType, OmitThisParameter - для функцій з типізованим this.

Навіщо знати утиліти:

  • похідні типи замість дублювання: тип відповіді API береться з функції запиту, тип аргументів - з функції, що їх приймає;
  • типи з бібліотек без експорту: якщо бібліотека не експортує тип опцій, Parameters<typeof libFn>[0] дістане його;
  • читабельність: стандартні назви зрозумілі всім, на відміну від власних умовних типів.

Пастка ReturnType з перевантаженими функціями: береться тип останньої сигнатури перевантаження, а не всіх. Для таких функцій похідні типи краще описати явно.

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

Перевантаження описують кілька способів викликати функцію з різними типами результату залежно від аргументів. Складаються з кількох сигнатур і однієї реалізації:

function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
  return typeof value === 'string' ? Number(value) : String(value);
}

parse('42');   // number
parse(42);     // string

Правила:

  • реалізація ззовні не видна - викликати функцію можна лише за сигнатурами перевантажень. Сигнатура реалізації має бути сумісною з усіма ними;
  • TypeScript перебирає перевантаження по черзі й бере перше відповідне - тому конкретніші ставлять вище за загальніші;
  • реалізація сама перевіряє, з яким варіантом її викликали (typeof, in), - компілятор не перевіряє, що кожна гілка повертає «правильний» тип для своєї сигнатури.

Головна пастка - аргумент-об'єднання:

declare const input: string | number;
parse(input);   // помилка: жодне перевантаження не приймає string | number

Кожне перевантаження приймає лише свій тип, а об'єднання не підходить жодному. Доводиться додавати ще одну сигнатуру (value: string | number): string | number.

Коли краще без перевантажень:

  • результат не залежить від типу аргументу - звичайний тип-об'єднання;
  • результат залежить від аргументу передбачувано - generic чи умовний тип:
function first<T>(items: T[]): T | undefined {
  return items[0];
}
  • різні способи виклику з різною кількістю параметрів - часто краще об'єкт параметрів чи кілька окремих функцій з виразними назвами (parseNumber, formatNumber).

Коли перевантаження доречні:

  • типи для API, яке вже так влаштоване - бібліотеки, .d.ts для JavaScript-коду (document.createElement('canvas') повертає HTMLCanvasElement - перевантаження в типах DOM);
  • залежність результату від літерального значення аргументу, яку незручно виразити умовним типом.

Перевантаження методів класу пишуться так само, а в типах об'єктів - кількома сигнатурами виклику.

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

У JavaScript значення this залежить від способу виклику, а не від місця оголошення. TypeScript дає змогу описати очікуваний this - і перевірити, що функцію викликають правильно.

Параметр this - перший «фіктивний» параметр, який не існує під час виконання:

function disable(this: HTMLButtonElement, event: MouseEvent) {
  this.disabled = true;
}

button.addEventListener('click', disable);   // ок: this - кнопка

const handler = disable;
handler(new MouseEvent('click'));   // помилка: this має тип void, а не HTMLButtonElement

У скомпільованому JavaScript параметр this зникає.

Методи класів - TypeScript знає, що this - екземпляр класу. Але при «відриві» методу від об'єкта this губиться під час виконання:

class Counter {
  count = 0;
  increment() { this.count++; }
}

const c = new Counter();
const inc = c.increment;
inc();   // TypeError під час виконання: this - undefined

Рішення:

  • стрілкова функція як поле - this захоплюється при створенні: increment = () => { this.count++; }; (ціна - окрема функція на кожен екземпляр, і її немає в прототипі);
  • bind у конструкторі;
  • параметр this: Counter у методі - тоді TypeScript сам покаже помилку при виклику без об'єкта.

this як тип результату - для ланцюжків викликів, що коректно працюють у нащадках:

class QueryBuilder {
  where(column: string, value: unknown): this {
    // ...
    return this;
  }
}

class PostQuery extends QueryBuilder {
  published(): this { return this.where('status', 'published'); }
}

new PostQuery().where('id', 1).published();   // where повернув PostQuery, а не QueryBuilder

Тип-перевірка this is T - type guard для методів:

class Shape {
  isCircle(): this is Circle { return this instanceof Circle; }
}

ThisParameterType<F> і OmitThisParameter<F> - утиліти, щоб отримати або прибрати тип this з типу функції (корисно для обгорток над методами).

Докладніше в документації: Оголошення this у функції

Абстрактний клас не можна створити напряму (new), лише успадкувати. Він може містити і реалізацію, і абстрактні члени, які зобов'язаний реалізувати нащадок.

abstract class Notifier {
  abstract send(to: string, message: string): Promise<void>;

  async notifyAll(recipients: string[], message: string) {
    for (const to of recipients) {
      await this.send(to, message);
    }
  }
}

class EmailNotifier extends Notifier {
  async send(to: string, message: string) {
    // реалізація відправки
  }
}

new Notifier();        // помилка: абстрактний клас
new EmailNotifier();   // ок

Нащадок, що не реалізував усі абстрактні члени, теж мусить бути abstract.

Чим відрізняється від інтерфейсу:

Абстрактний клас Інтерфейс
реалізація методів може мати ні
поля зі значеннями, конструктор так ні
модифікатори protected, private так ні
існує під час виконання так (це клас JavaScript) ні (зникає при компіляції)
кількість на клас лише один (extends) скільки завгодно (implements)
instanceof працює неможливо

Коли абстрактний клас:

  • є спільна реалізація, а нащадки відрізняються окремими кроками - шаблонний метод: загальний алгоритм у базовому класі, змінні кроки - абстрактні;
  • потрібен спільний стан чи конструктор;
  • потрібна перевірка instanceof.

Коли інтерфейс:

  • потрібен лише контракт - форма об'єкта чи класу;
  • клас має відповідати кільком контрактам;
  • об'єкти можуть бути не екземплярами класів (звичайні об'єкти, моки в тестах).

Абстрактні конструктори в типах - щоб прийняти «будь-який підклас», а не сам абстрактний клас:

function register(Ctor: abstract new () => Notifier) {}

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

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

Коли клас-нащадок перевизначає метод батька, ззовні не видно, чи це свідоме перевизначення, чи збіг назв. А при рефакторингу батьківського класу перевизначення може тихо зламатися.

Модифікатор override робить намір явним:

class Model {
  toArray(): Record<string, unknown> { return {}; }
}

class User extends Model {
  override toArray() {
    return { ...super.toArray(), name: this.name };
  }
}

Що перевіряє компілятор:

  • метод з override мусить існувати в батьківському класі. Якщо батьківський toArray перейменували на toObject, у нащадку з'являється помилка - замість тихо «осиротілого» методу, який більше ніхто не викликає;
  • з прапорцем noImplicitOverride - навпаки: перевизначення без override теж стає помилкою («This member must have an 'override' modifier because it overrides a member in the base class»).
{ "compilerOptions": { "noImplicitOverride": true } }

Які помилки це ловить:

  1. перейменування в батьківському класі - нащадок продовжує «перевизначати» метод, якого вже немає;
  2. випадкове перевизначення - у нащадку додали метод save(), не помітивши, що такий уже є в базовому класі, і зламали його логіку;
  3. друкарські помилки - toArary() з override одразу дасть помилку.

Аналогія з PHP: атрибут #[\Override] з PHP 8.3 робить те саме - перевіряє, що метод справді перевизначає батьківський.

Що варто знати:

  • override працює для методів, властивостей і аксесорів;
  • для реалізації абстрактних членів override дозволений, але навіть з noImplicitOverride не обов'язковий - багато команд усе одно пишуть його для однаковості;
  • модифікатор зникає при компіляції - на виконання не впливає.

Рекомендація: вмикати noImplicitOverride у проєктах, де є успадкування класів. Це ще один прапорець, який дешево ловить реальні помилки і не входить у strict.

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

Клас, як і функція, може мати параметри типу - тоді один клас працює з різними типами даних зі збереженням перевірок.

class Repository<T extends { id: number }> {
  #items = new Map<number, T>();

  save(item: T): void {
    this.#items.set(item.id, item);
  }

  find(id: number): T | undefined {
    return this.#items.get(id);
  }

  all(): T[] {
    return [...this.#items.values()];
  }
}

const users = new Repository<User>();
users.save({ id: 1, name: 'Оля' });
users.find(1)?.name;   // тип User

Обмеження extends гарантує, що в T є id - інакше item.id всередині класу був би помилкою.

Виведення параметра типу з конструктора:

class Box<T> {
  constructor(public value: T) {}
}

const box = new Box(42);   // Box<number> - тип виведено

Чому статичні члени не можуть використовувати T:

class Repository<T> {
  static defaultItem: T;   // помилка: static members cannot reference class type parameters
}

Параметр типу належить екземпляру: new Repository<User>() і new Repository<Post>() - різні «версії» з різними T. А статичний член один на весь клас - для всіх екземплярів одразу. Яким має бути T у Repository.defaultItem? Відповіді немає.

Якщо статичному методу потрібен generic, він оголошує власний параметр типу:

class Repository<T> {
  static of<U extends { id: number }>(items: U[]): Repository<U> {
    const repo = new Repository<U>();
    items.forEach((item) => repo.save(item));
    return repo;
  }
}

Корисні прийоми:

  • значення за замовчуванням для параметра: class Cache<V = string>;
  • кілька параметрів: class Store<State, Action extends { type: string }>;
  • this як тип у методах - для ланцюжків, що зберігають тип нащадка.

Обмеження: параметри типу зникають під час виконання. Усередині класу не можна написати new T() чи x instanceof T - якщо потрібен конструктор, його передають явно: constructor(private make: new () => T).

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

Принцип прапорця: імпорти й експорти лишаються в JavaScript рівно такими, як написані, крім тих, що явно позначені type. Компілятор більше не вирішує сам, які імпорти прибрати.

{ "compilerOptions": { "verbatimModuleSyntax": true } }

Що змінюється:

import { User } from './models.js';          // помилка: User - лише тип, використайте import type
import type { User } from './models.js';     // зникне повністю
import { type User, save } from './api.js';  // лишиться import { save } from './api.js'
import { type User } from './api.js';        // лишиться import {} from './api.js' (модуль завантажиться)
import './polyfills.js';                     // лишиться як є

Помилка: «'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled».

Навіщо:

1. Однаковий результат для всіх інструментів. Babel, esbuild, SWC, Vite, стирання типів у Node.js обробляють кожен файл окремо й не знають, чи є імпортована назва типом. Без явних type вони можуть залишити імпорт інтерфейсу (помилка під час виконання) або видалити імпорт, потрібний заради побічних ефектів. З verbatimModuleSyntax результат передбачуваний: що бачите, те й отримаєте.

2. Заміна старих прапорців. Він замінив importsNotUsedAsValues і preserveValueImports, а також бере на себе більшу частину задач isolatedModules.

3. Чіткі межі ESM і CommonJS. У файлах, що компілюються в CommonJS, прапорець забороняє ESM-синтаксис, який не можна перетворити буквально, - потрібно писати import x = require()/export =. Тому для CommonJS-проєктів він незручний; його природне середовище - ES-модулі й збирачі.

Пов'язані прапорці для сучасного проєкту:

  • isolatedModules - забороняє конструкції, які не можна скомпілювати пофайлово (наприклад, реекспорт типу без export type);
  • erasableSyntaxOnly - забороняє синтаксис, що потребує генерації коду (enum, parameter properties);
  • разом із verbatimModuleSyntax вони гарантують, що код можна просто «стерти» до JavaScript.

Міграція: правило @typescript-eslint/consistent-type-imports з автовиправленням переписує імпорти за кілька секунд.

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

Злиття оголошень - кілька оголошень з однаковою назвою в одній області видимості TypeScript об'єднує в одне.

Інтерфейси зливаються:

interface Box {
  width: number;
}

interface Box {
  height: number;
}

const box: Box = { width: 10, height: 20 };   // обидва поля обов'язкові

Правила:

  • поля, що не збігаються, додаються;
  • поле з тією самою назвою має бути того самого типу - інакше помилка;
  • методи з однаковою назвою стають перевантаженнями, причому пізніші оголошення мають пріоритет.

type не зливається: повторне type Box = ... - помилка «Duplicate identifier». Це головна практична відмінність interface від type.

Namespace зливається з класом, функцією чи enum - так додають «статичні» члени:

function formatPrice(amount: number): string {
  return `${amount} грн`;
}

namespace formatPrice {
  export const currency = 'UAH';
}

formatPrice(10);
formatPrice.currency;

Так описують у .d.ts бібліотеки, де функція має ще й властивості (як jQuery $ і $.ajax).

Де злиття використовують на практиці:

  • розширення чужих типів - доповнення модуля (module augmentation) і глобальних інтерфейсів (Window, ProcessEnv) спирається саме на злиття інтерфейсів;
  • декларації бібліотек, які поєднують функцію й об'єкт;
  • розширювані конфігурації - бібліотека оголошує порожній інтерфейс, а користувач доповнює його своїми полями, і бібліотека бачить їх типи (так зроблено реєстри маршрутів, подій, тем у багатьох бібліотеках).

Пастка: злиття працює й ненавмисно. Інтерфейс з назвою, яка вже є глобально (наприклад, ваш interface Event у файлі-скрипті), зіллється з вбудованим Event DOM. Тому власні типи варто тримати в модулях (файлах з import/export), де вони не потрапляють у глобальну область.

Що не зливається: класи з класами, type з будь-чим, змінні.

Докладніше в документації: Злиття оголошень

Доповнення модуля (module augmentation) додає поля до інтерфейсів, оголошених у чужому пакеті, не змінюючи сам пакет. Працює через злиття інтерфейсів.

Приклад з Vue - глобальна властивість у шаблонах:

// src/types/vue.d.ts
import type { Translator } from '../i18n';

declare module 'vue' {
  interface ComponentCustomProperties {
    $t: Translator;
  }
}

Тепер $t має тип у всіх шаблонах і this в Options API.

Приклад з Pinia - власна опція стора, з Vue Router - типізоване meta:

import 'vue-router';

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean;
    title?: string;
  }
}

Express - поле в запиті. Типи Express оголошені в глобальному просторі імен, тому доповнюють його через declare global:

import type { User } from './models.js';

declare global {
  namespace Express {
    interface Request {
      user?: User;
    }
  }
}

Обов'язкові умови:

  1. файл має бути модулем - містити хоча б один import чи export на верхньому рівні (часто додають export {}). У файлі-скрипті declare module 'vue' замінить оголошення модуля замість доповнення - і всі типи Vue зникнуть. Це дзеркальна протилежність ситуації з declare module '*.svg', якому, навпаки, потрібен скрипт;
  2. назва модуля має точно збігатися з тим, що імпортують ('vue', а не '@vue/runtime-core', якщо бібліотека радить саме 'vue');
  3. доповнювати можна лише існуючі інтерфейси - нові експорти чи нові модулі так не додаються;
  4. файл має потрапити в компіляцію (include у tsconfig).

Бібліотеки часто проєктують такі точки розширення навмисно: порожній інтерфейс-«реєстр», який користувач доповнює, - і всі API бібліотеки автоматично отримують точні типи (події, маршрути, теми, сховища).

Пастка: доповнення діють глобально для всієї програми. Поле, додане до Request у одному місці, видно всюди - тож тип має бути чесним (user?: User, а не user: User, якщо middleware автентифікації працює не на всіх маршрутах).

Докладніше в документації: Злиття оголошень: доповнення модуля

Інколи значення справді глобальне: дані, які сервер вбудовує в сторінку (window.App = {...} з Blade), скрипт аналітики, змінні середовища збирача. TypeScript треба про них розповісти.

Доповнення Window з файлу-модуля:

// src/types/global.d.ts
import type { User } from '../models';

declare global {
  interface Window {
    App: {
      locale: string;
      user: User | null;
    };
    dataLayer: unknown[];
  }
}

export {};

declare global працює лише у файлі-модулі (з import/export). export {} наприкінці перетворює файл на модуль, якщо інших імпортів немає.

У файлі-скрипті (без імпортів) глобальні оголошення пишуть без обгортки:

// globals.d.ts
interface Window {
  dataLayer: unknown[];
}
declare const __APP_VERSION__: string;   // значення, підставлене збирачем (define у Vite)

Змінні середовища Vite:

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_APP_NAME: string;
  readonly VITE_API_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Тепер import.meta.env.VITE_API_URL має тип рядка, а друкарська помилка в назві - помилку компіляції.

process.env у Node.js - через простір імен NodeJS:

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      NODE_ENV: 'development' | 'production' | 'test';
    }
  }
}

Пастки:

  • оголошення - не гарантія. TypeScript повірить, що window.App.user існує, навіть якщо сервер його не передав. Для даних ззовні краще перевірка під час виконання (схема Zod) і чесні типи з | undefined;
  • змінні середовища оголошені як string, але під час виконання можуть бути відсутні - валідація конфігурації при старті надійніша за самі типи;
  • файл не в include - найчастіша причина, чому оголошення «не бачить» компілятор;
  • var у declare global додає властивість і в globalThis, а let/const - ні; для globalThis.x потрібен саме var.

Докладніше в документації: Злиття оголошень: глобальне доповнення

Якщо в пакета немає власних типів і пакета @types/..., TypeScript повідомляє «Could not find a declaration file for module 'tiny-slug'» і вважає імпорт any (або дає помилку в строгому режимі).

Крок 1 - оголошення модуля в проєкті:

// src/types/tiny-slug.d.ts
declare module 'tiny-slug' {
  export interface SlugOptions {
    separator?: string;
    lower?: boolean;
  }

  export default function slugify(text: string, options?: SlugOptions): string;
  export function isSlug(value: string): boolean;
}

Файл має бути скриптом (без import/export на верхньому рівні) і потрапляти в include.

Крок 2 - описати реальну форму експорту. Тут найчастіше помиляються:

  • ESM export default - як у прикладі;
  • CommonJS module.exports = fn - у декларації це export = fn:
declare module 'legacy-lib' {
  function legacy(input: string): string;
  namespace legacy {
    const version: string;
  }
  export = legacy;
}

Імпортувати такий модуль: import legacy from 'legacy-lib' (з esModuleInterop, який у TypeScript 6/7 завжди увімкнений).

Як перевірити форму - подивитися в код пакета (main/exports у його package.json) і що реально повертає require/import у Node.js.

Крок 3 - описувати лише використане. Не обов'язково типізувати весь API бібліотеки - досить того, що викликає ваш код. Решту можна додати пізніше.

Швидкий тимчасовий варіант:

declare module 'tiny-slug';   // усе з пакета - any

Помилка зникає, але й перевірки теж. Варто лише як тимчасовий захід.

Типи для глобальної бібліотеки (підключена через <script>, створює window.Chart) - declare const Chart: ... у глобальному .d.ts.

Що далі:

  • якщо декларації якісні й бібліотека популярна - запропонувати їх у DefinitelyTyped (пакет @types/...), щоб скористалися інші;
  • ще краще - запропонувати PR у саму бібліотеку з типами або JSDoc-анотаціями (TypeScript уміє генерувати .d.ts з JavaScript з JSDoc);
  • перевіряти, чи не з'явилися власні типи в новій версії пакета: тоді локальні декларації треба видалити, бо вони перекриватимуть справжні.

Докладніше в документації: Шаблон module.d.ts

Обидві опції закривають «дірки», які лишає навіть strict, тому їх часто вмикають додатково.

noUncheckedIndexedAccess - доступ за індексом чи довільним ключем може повернути undefined:

const items: string[] = [];
const first = items[0];        // без опції: string, з опцією: string | undefined
first.toUpperCase();           // з опцією: помилка 'first' is possibly 'undefined'

const prices: Record<string, number> = {};
prices['coffee'].toFixed(2);   // з опцією: помилка

Без опції TypeScript вважає, що елемент масиву чи значення словника завжди є, - і items[0] на порожньому масиві падає під час виконання.

Поведінка, яку варто знати:

  • for...of, map, forEach не потребують перевірок - елемент там точно існує;
  • кортежі з відомою довжиною ([string, number]) не уражені;
  • Map.get() і так повертає T | undefined незалежно від опції.

exactOptionalPropertyTypes - розрізняє «властивості немає» і «властивість є і дорівнює undefined»:

type Options = { theme?: 'dark' | 'light' };

const a: Options = {};                     // гаразд
const b: Options = { theme: undefined };   // з опцією: помилка

Це важливо, бо в JavaScript ці стани поводяться по-різному: 'theme' in options, Object.keys, розгортання { ...defaults, ...options } - явний undefined перезапише значення за замовчуванням. Якщо undefined справді допустиме, його вказують явно: theme?: 'dark' | 'light' | undefined.

Чому вони не в strict:

  • ціна для наявного коду: увімкнення в старому проєкті дає сотні помилок, частина з яких - перевірки там, де розробник «знає», що значення є;
  • шум: arr[i] у звичайному циклі for з індексом теж вимагає перевірки чи !;
  • сумісність з бібліотеками: exactOptionalPropertyTypes виявляє розбіжності в типах сторонніх пакетів.

Рекомендація: у нових проєктах вмикати обидві (пресет @tsconfig/strictest так і робить). У наявних - noUncheckedIndexedAccess у першу чергу: він ловить реальні помилки «undefined is not an object».

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

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

bundler - для коду, який збирає Vite, webpack, esbuild чи виконує Bun:

import { formatPrice } from './utils';        // без розширення - гаразд
import Button from '@/components/Button.vue';
  • розширення файлів можна не вказувати;
  • підтримує поле exports у package.json залежностей;
  • поєднується з module: "esnext" або "preserve" (з TypeScript 6.0 - і з commonjs).

nodenext - для коду, який напряму виконує Node.js (сервер, CLI, бібліотеки для Node):

import { formatPrice } from './utils.js';   // розширення обов'язкове
  • точно моделює Node.js: ES-модуль чи CommonJS визначається полем "type" у package.json і розширенням (.mts, .cts);
  • розширення обов'язкові у відносних імпортах ES-модулів, як вимагає Node.js;
  • іде в парі з module: "nodenext".

Чому розширення .js, якщо файл .ts: TypeScript не переписує шляхи в імпортах, а після компіляції поруч буде utils.js. Для коду, що виконується через вбудоване зняття типів Node.js, використовують .ts в імпортах разом з опцією rewriteRelativeImportExtensions (для генерації .js) або allowImportingTsExtensions (якщо JavaScript не генерується).

Що обрати:

Код moduleResolution
фронтенд на Vite (Laravel + Vue/React) bundler
сервер чи скрипти, що запускає Node.js nodenext
бібліотека для npm nodenext - так перевірка суворіша й результат працює всюди

Застарілі варіанти: node (він же node10) моделював Node.js 10 і не знав про exports; classic - алгоритм з часів до Node.js. Обидва в TypeScript 7 видалено - помилка «Option 'moduleResolution=node10' has been removed».

Ознака неправильного вибору: TypeScript без помилок перевіряє імпорт, який падає під час виконання (або навпаки). bundler для коду, що запускає Node.js, - типова причина.

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

Vite, esbuild, oxc, swc і Node.js перетворюють TypeScript на JavaScript по одному файлу і без перевірки типів - просто прибирають анотації. Це швидко, але такий інструмент не бачить інших файлів проєкту. Частина конструкцій TypeScript без знання інших файлів перетворюється неправильно.

Головна проблема - імпорт типу:

// types.ts
export type User = { id: number };

// app.ts
import { User } from './types';

Компілятор TypeScript знає, що User - тип, і прибере імпорт. Інструмент, що бачить лише app.ts, цього не знає: залишить import { User } from './types' - і в браузері помилка «The requested module does not provide an export named 'User'».

isolatedModules - TypeScript попереджає про код, який неможливо безпечно перетворити по одному файлу: реекспорт типу без type, const enum між файлами, файли без імпортів і експортів.

verbatimModuleSyntax - строгіший і простіший підхід: імпорти лишаються в JavaScript рівно так, як написані, крім позначених type. Тому тип треба явно позначити:

import type { User } from './types';
import { fetchUser, type UserFilter } from './api';

З опцією TypeScript видасть помилку на import { User }, якщо User - лише тип.

Що обрати: у сучасних проєктах - verbatimModuleSyntax: true. Він робить поведінку однаковою для tsc, збирачів і Node.js і замінює старіші importsNotUsedAsValues і preserveValueImports. Шаблони Vite вмикають його за замовчуванням.

Наслідки, які варто знати:

  • імпорти з побічними ефектами зберігаються: import './styles.css' нікуди не зникне;
  • import type не виконує модуль - якщо вам потрібен побічний ефект модуля, потрібен звичайний імпорт;
  • з CommonJS опція змушує писати import x = require('...') для CommonJS-виводу - у кодовій базі на ES-модулях це не відчувається;
  • лінтер (@typescript-eslint/consistent-type-imports) автоматично виправляє імпорти, тож перехід на велику кодову базу - один прогін автовиправлення.

Зв'язок з Node.js: вбудоване виконання TypeScript у Node.js теж прибирає лише імпорти з type - без verbatimModuleSyntax такі помилки виявляться лише під час запуску.

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

Vite лише прибирає типи - перетворює TypeScript на JavaScript без перевірки. Код з помилкою типу const n: number = 'text' спокійно збереться й запуститься. Це навмисне рішення: перевірка типів - повільна операція, що потребує аналізу всього проєкту, а Vite перетворює файли по одному за мілісекунди.

Розподіл обов'язків:

  • редактор (мовний сервер TypeScript, для Vue - розширення Vue - Official) показує помилки під час написання коду;
  • окрема команда перевірки - у збірці й CI.

Типова конфігурація package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "typecheck": "tsc --noEmit"
  }
}
  • tsc --noEmit - перевірити типи без генерації файлів;
  • для Vue - vue-tsc --noEmit: звичайний tsc не розуміє .vue-файли й не перевіряє шаблони;
  • для проєкту з кількома tsconfig (застосунок і конфіги Node) - tsc -b.

Якщо перевірка типів у build надто сповільнює збірку, її виносять в окремий крок CI, що виконується паралельно зі збиранням.

Помилки типів під час розробки в браузері: плагін vite-plugin-checker запускає перевірку в окремому процесі й показує помилки поверх сторінки.

TypeScript 7 змінює баланс. Нативний компілятор перевіряє великий проєкт у 8-12 разів швидше - перевірка в build і в режимі --watch стає майже непомітною. Але TypeScript 7.0 ще не має програмного API, а інструменти, що його використовують (typescript-eslint підтримує лише TypeScript до 6.x, vue-tsc теж працює через API компілятора), потребують TypeScript 6 - тому в проєкті може знадобитися пакет сумісності @typescript/typescript6 поряд із TypeScript 7.

Наслідки «перевірки лише в редакторі»:

  • помилки в файлах, які ніхто не відкривав, лишаються непоміченими;
  • зміна типу в одному місці ламає десятки інших файлів - редактор покаже це лише у відкритих;
  • тому перевірка в CI обов'язкова, навіть якщо в команді всі користуються редактором з підтримкою TypeScript.

Обмеження, що випливають з покофайлового перетворення, - isolatedModules/verbatimModuleSyntax у tsconfig.json, щоб TypeScript попереджав про конструкції, які Vite перетворить неправильно.

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

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

Рівні
Junior 36 Middle 35 Senior 29

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