.d.ts-файлы: ambient-объявления, аугментация и публичная поверхность типов
`.d.ts`-файлы описывают формы без реализации: ambient-объявления, `declare module`, `declare global`, module-аугментация. Ловушки: аугментация, которая молча не сливается, случайное загрязнение глобального пространства и emit типов со ссылками на приватные пути.
Ты хотел добавить поле user в Request Express, чтобы auth-мидлвара могла типизировать req.user. Ты написал declare global { namespace Express { interface Request { user?: User } } } в types.ts — и аугментация просто не применилась; req.user остался ошибкой. В файле был импорт верхнего уровня, что сделало его модулем, поэтому declare global оказался в неверной области видимости, и слияние молча провалилось. Declaration merging безжалостно к тому, где ты объявляешь.
declare: формы без реализаций
.d.ts-файл содержит только информацию о типах — исполняемый код запрещён, поэтому всё declare-ится. Так описывают значение, которое существует в рантайме, но которое TypeScript не видит (глобал из тега script, нетипизированный npm-пакет):
// globals.d.ts — ambient-объявления (нет import/export = это СКРИПТ, глобальная область)
declare const __APP_VERSION__: string; // внедряется бандлером во время сборки
declare function gtag(...args: unknown[]): void;
interface Window { // сливается со встроенным Window
dataLayer: unknown[];
}Файл без import/export верхнего уровня — это скрипт: его объявления глобальны. Файл с любым import/export верхнего уровня — это модуль: его объявления локальны, если не обёрнуты в declare global. Это различие module-vs-script — корень большинства провалов аугментации.
Ambient-модули: типизация нетипизированной зависимости
Когда ты import-ируешь JS-пакет без типов и без @types/..., TypeScript даёт ошибку TS7016. declare module "name" изобретает ambient-объявление модуля, чтобы закрыть пробел:
// shims.d.ts
declare module "legacy-chart" {
export interface ChartOptions { width: number; height: number }
export function render(el: HTMLElement, opts: ChartOptions): void;
const _default: { version: string };
export default _default;
}Wildcard-форма типизирует целые классы импортов — например, импорты ассетов под бандлером:
declare module "*.svg" {
const url: string; // загрузчик Vite/webpack превращает файл в строку-URL
export default url;
}Module-аугментация: расширение чужих типов
Чтобы добавить к типу, который экспортирует другой модуль, переоткрой этот модуль через declare module изнутри своего модуля (файл должен иметь собственный import/export):
// augment.ts — есть импорты, значит это МОДУЛЬ
import "express"; // гарантируем загрузку целевого модуля
declare module "express-serve-static-core" {
interface Request { user?: { id: string; roles: string[] } } // сливается с Request из Express
}
export {}; // для надёжности: гарантирует контекст модуляДля глобальных типов (Window, Array, globalThis) аугментацию надо обернуть в declare global — и охватывающий файл всё равно должен быть модулем:
// env.d.ts
export {}; // делает файл модулем, чтобы `declare global` был здесь легален
declare global {
interface Window { __APP_VERSION__: string }
namespace NodeJS { interface ProcessEnv { DATABASE_URL: string } }
}Emit и поставка типов
Когда публикуешь библиотеку, поставляемые .d.ts-файлы и есть твой API-контракт для редактора и проверщика типов каждого потребителя. Сделай emit неверно — и потребители увидят ошибки TS4023 или сломанный «go to definition», даже если рантайм работает нормально.
declaration: true заставляет tsc генерировать .d.ts рядом с каждым .js. declarationMap: true генерирует .d.ts.map, чтобы «go to definition» у потребителя прыгал в твой настоящий исходник, а не в сгенерированный .d.ts. В package.json ты указываешь потребителям на них через types (или условие types в exports):
{
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }
}typeRoots/types (опции компилятора, отличные от поля в package.json) управляют тем, какие @types-пакеты включаются: types: [] отключает авто-включение каждого @types/* из node_modules — частый фикс для «глобал из какого-то транзитивного @types загрязнил мой проект».
▸Почему это работает
Почему публичный экспорт, ссылающийся на неэкспортированный тип, вызывает TS4023: Exported variable has or is using name 'X' from external module but cannot be named? Потому что .d.ts потребителя должен уметь именовать каждый тип в твоей публичной поверхности. Если export function make(): Internal возвращает тип Internal, который ты импортировал, но не экспортировал, сгенерированный .d.ts сослался бы на путь, который потребитель не может импортировать — сломанный публичный API. Фикс — экспортировать Internal тоже или встроить его форму. По той же причине ломается поставка типов, ссылающихся на приватные/src-пути: опубликованный .d.ts указывает на файлы, которых нет в пакете.
export type vs export
В .d.ts, который ты пишешь руками, предпочитай export type { Foo } для type-only экспортов, чтобы потребители (и однофайловые транспиляторы) знали, что он не несёт runtime-значения. Обычный export { Foo }, где Foo — тип, работает для потребителей типов, но неоднозначен для инструментов, стирающих типы пофайлово.
Расставь шаги, чтобы корректно аугментировать Request из Express полем `user`, чтобы слияние реально применилось:
- 1 Сделай файл модулем: дай ему import/export верхнего уровня (или добавь `export {}`)
- 2 Открой правильный целевой модуль: `declare module "express-serve-static-core"`
- 3 Переобъяви interface Request { user?: ... }, чтобы он СЛИЛСЯ с оригиналом
- 4 Убедись, что tsconfig включает .d.ts (через include/files), чтобы аугментация загрузилась
Ты добавляешь `declare global { interface Window { x: number } }` в файл, где наверху уже есть `import { z } from './z'`, но нет `export`. Почему аугментация может вести себя неожиданно?
- 01Что за правило module-vs-script для .d.ts-файлов и почему оно ломает аугментацию?
- 02Как типизировать нетипизированную зависимость против расширения уже типизированной?
- 03Что поставляет declaration emit и какие ловушки утечки/загрязнения?
Теперь ты умеешь автором и поставщиком создавать чистую поверхность типов: .d.ts несёт только типы, правило module-vs-script решает, глобальны твои объявления или локальны, declare module и declare global расширяют зависимости и глобалы (когда контекст верен), а declaration/declarationMap генерируют публичную поверхность, которая не должна утекать приватными путями. Дальше — project references: когда одна кодовая база разрастается во многие пакеты, как tsc --build упорядочивает работу, делит типы между пакетами и остаётся быстрым с .tsbuildinfo? Теперь, когда аугментация молча не применяется, проверяй контекст первым делом — случайный import или забытый export {} почти всегда окажется причиной.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.