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

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

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

5 питань

Принцип прапорця: імпорти й експорти лишаються в 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