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

Junior: питання на співбесіді з теми «Модулі й декларації»

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

5 питань

TypeScript використовує синтаксис ES-модулів: файл з import чи export на верхньому рівні - модуль зі своєю областю видимості.

// money.ts
export function formatPrice(amount: number): string {
  return `${amount.toFixed(2)} грн`;
}
export type Currency = 'UAH' | 'USD';

// app.ts
import { formatPrice, type Currency } from './money.js';

Файл без import/export - скрипт: його оголошення потрапляють у глобальну область. Тому в TypeScript-файлах інколи пишуть порожній export {} - щоб файл став модулем.

Чому .js в імпорті .ts-файлу. TypeScript не переписує шляхи імпортів при компіляції: що написано в коді, те й опиниться в зібраному JavaScript. Після компіляції money.ts стане money.js, і Node.js шукатиме саме ./money.js. Тому з moduleResolution: "nodenext" відносні імпорти пишуть з розширенням виконуваного файлу - .js, а TypeScript розуміє, що йдеться про money.ts.

Що залежить від налаштувань:

  • moduleResolution: "bundler" (Vite, webpack, esbuild) - розширення можна не писати: збирач сам знайде файл. Найзручніше для фронтенду;
  • moduleResolution: "nodenext" - правила Node.js: розширення обов'язкове для ES-модулів, а тип модуля (ESM чи CommonJS) визначається полем "type" у package.json або розширенням .mts/.cts;
  • запуск .ts напряму (стирання типів у Node.js, Bun, Deno) - можна імпортувати з .ts за allowImportingTsExtensions; для бібліотек, що компілюються, rewriteRelativeImportExtensions перепише .ts на .js у результаті.

TypeScript 6/7: старі режими moduleResolution: "node" (node10) і "classic" видалено - лишилися nodenext і bundler. Типове значення module - esnext.

Корисне правило: спосіб резолюції модулів у tsconfig має відповідати тому, хто реально виконує код - збирач, Node.js чи інший рушій. Інакше TypeScript погоджуватиметься з імпортами, які не працюють під час виконання.

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

import type імпортує лише тип - такий імпорт гарантовано зникає з JavaScript після компіляції.

import type { User } from './models.js';
import { fetchUser, type Role } from './api.js';   // змішаний: fetchUser - значення, Role - тип

export type { User };

Навіщо, якщо TypeScript і так прибирає невикористані в коді імпорти типів:

1. Інструменти, що працюють з одним файлом. Babel, esbuild, SWC, стирання типів у Node.js перетворюють кожен файл окремо, не знаючи, що в іншому файлі User - це інтерфейс, а не клас. Для рядка import { User } from './models.js' вони не можуть вирішити, чи лишати імпорт. import type знімає неоднозначність.

2. Помилки виконання. Якщо імпорт типу лишився в JavaScript, а модуль нічого з такою назвою не експортує (інтерфейси під час виконання не існують), ES-модуль впаде: «The requested module does not provide an export named 'User'».

3. Побічні ефекти й цикли. Звичайний імпорт завантажує модуль і виконує його код. import type - ні. Це розриває циклічні залежності, що існують лише на рівні типів, і не тягне важкі модулі заради одного типу.

4. Читабельність - видно, що з модуля потрібні лише типи.

Прапорець verbatimModuleSyntax робить це обов'язковим: імпорт без type, у якому лише типи, - помилка компіляції. Рекомендований для нових проєктів.

Пастки:

  • import type { Foo } не можна використати як значення - new Foo() чи Foo.staticMethod() дадуть помилку. Для класу, який використовується і як тип, і як значення, - звичайний імпорт;
  • import type X from (за замовчуванням) і import type * as ns from теж працюють;
  • різниця import type { A } і import { type A }: з verbatimModuleSyntax перший зникає повністю, а другий лишає import {} from './mod.js' - модуль усе одно завантажиться заради побічних ефектів.

Лінтер (@typescript-eslint/consistent-type-imports) автоматично виправляє імпорти на import type.

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

Файл декларацій .d.ts описує лише типи - без реалізації. Він каже TypeScript, які функції, класи й змінні існують і якого вони типу, а сам код живе деінде (у JavaScript-файлі).

// slugify.d.ts
export default function slugify(text: string, options?: { lower?: boolean }): string;

Звідки беруться декларації:

1. Пакет сам постачає типи. Бібліотека, написана на TypeScript, генерує .d.ts при збиранні (declaration: true) і вказує їх у package.json. Більшість сучасних пакетів так і роблять - нічого встановлювати не треба.

2. Пакети @types/* - для бібліотек, написаних на JavaScript без власних типів. Їх пишуть і підтримують спільнотою в репозиторії DefinitelyTyped:

npm install -D @types/lodash

Версія @types/lodash повторює мажорну й мінорну версію самої бібліотеки - їх варто тримати узгодженими.

3. Вбудовані декларації TypeScript - lib.dom.d.ts, lib.es2025.d.ts тощо. Набір обирається опціями target і lib.

4. Власні декларації в проєкті - для бібліотек без типів, глобальних змінних, файлів-ресурсів (*.svg, *.css).

Як TypeScript знаходить типи імпорту: спершу в самому пакеті (types/exports у package.json), потім у node_modules/@types/назва-пакета.

Важлива зміна в TypeScript 6/7: опція types за замовчуванням тепер порожня ([]). Раніше TypeScript автоматично підключав усі пакети з node_modules/@types як глобальні - через це в кожному проєкті були типи process, describe тощо. Тепер глобальні типи треба перелічити явно:

{ "compilerOptions": { "types": ["node", "vitest/globals"] } }

На типи пакетів, які імпортуються (import _ from 'lodash'), це не впливає - лише на глобальні.

skipLibCheck: true - не перевіряти .d.ts залежностей: значно прискорює збирання, а помилки в чужих деклараціях вам однаково не виправити.

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

Типовий симптом після оновлення: десятки помилок «Cannot find name 'process'», «Cannot find name 'describe'», «Cannot find module 'fs'».

Причина - нове значення опції types за замовчуванням.

  • До TypeScript 6: якщо types не вказано, компілятор автоматично підключав усі пакети з node_modules/@types як глобальні декларації. Встановлено @types/node - і process доступний будь-де.
  • TypeScript 6 і 7: types за замовчуванням - порожній масив. Жоден пакет @types не підключається глобально сам по собі.

Рішення - перелічити потрібні глобальні типи явно:

{
  "compilerOptions": {
    "types": ["node", "jest"]
  }
}

Або для різних частин проєкту - різні tsconfig (тести бачать vitest/globals, а код застосунку - ні).

Повернути стару поведінку можна значенням "types": ["*"], але команда TypeScript радить явний список.

Навіщо це змінили:

  • швидкість: у сучасних репозиторіях node_modules/@types містить сотні пакетів, підтягнутих транзитивно. Їх розбір і перевірка займали помітну частину збирання - за даними команди TypeScript, явний types прискорював збирання багатьох проєктів на 20-50%;
  • передбачуваність: глобальні типи тестового фреймворку не «протікають» у код застосунку (у браузерному коді раптом доступний describe чи process).

На що це НЕ впливає: на типи пакетів, які ви імпортуєте. import express from 'express' знайде @types/express як і раніше. Опція types стосується лише глобальних оголошень.

Інші зміни TypeScript 6/7, що дають схожі «раптові» помилки:

  • rootDir тепер за замовчуванням - каталог з tsconfig.json. Якщо код у src/, а результат раптом з'являється в dist/src/, треба вказати "rootDir": "./src";
  • strict: true за замовчуванням - проєкти, що покладалися на нестрогий режим, мають явно вказати "strict": false (краще - виправити помилки).

Порада: оновлюватися через TypeScript 6 - він показує попередження про застарілі налаштування, а TypeScript 7 робить їх помилками.

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

Збирачі (Vite, webpack) дозволяють імпортувати не лише код: import logo from './logo.svg'. TypeScript про такі файли нічого не знає і повідомляє «Cannot find module './logo.svg'».

Рішення - ambient-оголошення модуля з шаблоном:

// src/assets.d.ts
declare module '*.svg' {
  const src: string;
  export default src;
}

declare module '*.module.css' {
  const classes: Readonly<Record<string, string>>;
  export default classes;
}

Тепер будь-який імпорт, що закінчується на .svg, має тип рядка (URL файлу).

Vite вже має такі оголошення - досить підключити їх: "types": ["vite/client"] у tsconfig або /// <reference types="vite/client" /> у файлі vite-env.d.ts. Там описано і ?url, ?raw, ?inline, і import.meta.env.

Чому declare module '*.svg' інколи «не працює». Найчастіша причина - файл з оголошенням є модулем: у ньому є import чи export на верхньому рівні.

// assets.d.ts - НЕ спрацює
import type { FC } from 'react';
declare module '*.svg' {
  const Component: FC;
  export default Component;
}

У файлі-модулі declare module 'назва' - це доповнення (augmentation) існуючого модуля, а доповнити шаблон, якого немає, неможливо. Ambient-оголошення нових модулів мають бути у скрипті - .d.ts без імпортів на верхньому рівні. Якщо тип потрібен з іншого пакета - імпорт всередині блоку:

declare module '*.svg?react' {
  import type { FC, SVGProps } from 'react';
  const Component: FC<SVGProps<SVGSVGElement>>;
  export default Component;
}

Інші причини:

  • файл з оголошеннями не входить у include чи files у tsconfig;
  • шаблон не збігається ('*.svg' проти імпорту з ?react на кінці).

Обережно з «заглушками»: declare module 'some-lib'; без тіла робить усі імпорти з пакета типу any - це прибирає помилку, але й будь-яку перевірку. Для бібліотек краще знайти чи написати справжні типи.

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