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 з автовиправленням переписує імпорти за кілька секунд.
Злиття оголошень - кілька оголошень з однаковою назвою в одній області видимості 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;
}
}
}
Обов'язкові умови:
- файл має бути модулем - містити хоча б один
importчиexportна верхньому рівні (часто додаютьexport {}). У файлі-скриптіdeclare module 'vue'замінить оголошення модуля замість доповнення - і всі типи Vue зникнуть. Це дзеркальна протилежність ситуації зdeclare module '*.svg', якому, навпаки, потрібен скрипт; - назва модуля має точно збігатися з тим, що імпортують (
'vue', а не'@vue/runtime-core', якщо бібліотека радить саме'vue'); - доповнювати можна лише існуючі інтерфейси - нові експорти чи нові модулі так не додаються;
- файл має потрапити в компіляцію (
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); - перевіряти, чи не з'явилися власні типи в новій версії пакета: тоді локальні декларації треба видалити, бо вони перекриватимуть справжні.