open atlas
↑ К треку
Система типов TypeScript вглубь TS · 07 · 03

.d.ts-файлы: ambient-объявления, аугментация и публичная поверхность типов

`.d.ts`-файлы описывают формы без реализации: ambient-объявления, `declare module`, `declare global`, module-аугментация. Ловушки: аугментация, которая молча не сливается, случайное загрязнение глобального пространства и emit типов со ссылками на приватные пути.

TS Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Ты хотел добавить поле 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. 1 Сделай файл модулем: дай ему import/export верхнего уровня (или добавь `export {}`)
  2. 2 Открой правильный целевой модуль: `declare module "express-serve-static-core"`
  3. 3 Переобъяви interface Request { user?: ... }, чтобы он СЛИЛСЯ с оригиналом
  4. 4 Убедись, что tsconfig включает .d.ts (через include/files), чтобы аугментация загрузилась
Викторина

Ты добавляешь `declare global { interface Window { x: number } }` в файл, где наверху уже есть `import { z } from './z'`, но нет `export`. Почему аугментация может вести себя неожиданно?

Вспомните перед уходом
  1. 01
    Что за правило module-vs-script для .d.ts-файлов и почему оно ломает аугментацию?
  2. 02
    Как типизировать нетипизированную зависимость против расширения уже типизированной?
  3. 03
    Что поставляет declaration emit и какие ловушки утечки/загрязнения?
Итог

Теперь ты умеешь автором и поставщиком создавать чистую поверхность типов: .d.ts несёт только типы, правило module-vs-script решает, глобальны твои объявления или локальны, declare module и declare global расширяют зависимости и глобалы (когда контекст верен), а declaration/declarationMap генерируют публичную поверхность, которая не должна утекать приватными путями. Дальше — project references: когда одна кодовая база разрастается во многие пакеты, как tsc --build упорядочивает работу, делит типы между пакетами и остаётся быстрым с .tsbuildinfo? Теперь, когда аугментация молча не применяется, проверяй контекст первым делом — случайный import или забытый export {} почти всегда окажется причиной.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.