Питання на співбесіді: Модулі й декларації
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
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.
Файл декларацій .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 робить їх помилками.
Збирачі (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 - це прибирає помилку, але й будь-яку перевірку. Для бібліотек краще знайти чи написати справжні типи.
Принцип прапорця: імпорти й експорти лишаються в 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); - перевіряти, чи не з'явилися власні типи в новій версії пакета: тоді локальні декларації треба видалити, бо вони перекриватимуть справжні.
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="..." /> знову стала корисним способом підключити глобальні типи для окремого файлу.
Простори імен (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- перевіряє, що типи правильно резолвляться для всіх режимів (node16ESM/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 сам по собі.