《TypeScript编程入门》12.1 .d.ts 与 @types 机制

本节从类型擦除讲起,解释为什么编译产物 .js 里不剩任何类型信息、消费方却依然能获得完整补全,从而引出 .d.ts 声明文件。我们会拆开 declare 的每一种用法,讲清「脚本」与「模块」在声明文件里的分水岭,梳理 @types 的查找顺序、typeRoots 与 types 的差别以及三斜线指令,并亲手写一个最小可用的声明文件。读完你能独立排查「找不到模块的类型声明」这类报错。

本节目标:读完这一节,你能说清 .d.ts 在编译流程中的位置,能区分手写声明、tsc --declaration 自动生成与 @types 社区包三条来源,能解释 declare 关键字的每种用法,能配置 typeRoots 与 types 控制全局类型注入,并在遇到「找不到模块的类型声明」时自己定位原因。

12.1 .d.ts 与 @types 机制

第 11 章我们讲了模块解析与 npm 包的类型入口。这一节往下钻一层:那些类型到底是从哪来的?

一个只用 JavaScript 写的库,npm install 之后在 TypeScript 里却能有完整的自动补全,这中间靠的就是声明文件。

类型擦除留下的空缺

TypeScript 的所有类型都只存在于编译期。编译产物里一个类型标注都不剩:

// src/greet.ts
export function greet(name: string): string {
  return `Hello, ${name}`;
}

经过 tsc 之后得到:

// dist/greet.js
export function greet(name) {
  return `Hello, ${name}`;
}

string 没了。这就带来一个根本矛盾:一个包一旦发布,带出去的就只有 .js;下一个用 TypeScript 的消费者拿到的是一堆没有类型的函数。

解决办法不是把类型塞回 .js(运行时不需要它,还会拖大体积),而是另发一个只描述类型的平行文件。这就是 .d.ts。

.d.ts 里能写什么

.d.ts 文件里只有类型信息,没有一行可执行代码。它的产出物是零——tsc 遇到 .d.ts 只会读取,不会为它生成 .js:

// types/greet.d.ts
export declare function greet(name: string): string;

注意这里的 declare:它表示「这个东西的实现不在这里,我只是告诉你它长什么样」。所以在 .d.ts 里:

写法是否合法说明
declare function f(): void;合法只声明签名
function f() {}非法声明文件里不允许出现实现
declare const VERSION: string;合法声明一个运行时存在的常量
interface User { id: number }合法类型本身不需要 declare
type ID = string;合法同上
const VERSION = "1.0";非法初始化表达式也是实现

规则可以归纳成一句话:declare 标记的是「有运行时实体、但没有实现」的东西(函数、变量、类、枚举、模块、命名空间);纯粹的类型(interface、type)本来就不产生运行时代码,不需要 declare。

写错了编译器会直接拒绝:

An implementation cannot be declared in ambient contexts. ts(1183)

三条来源

工程里的 .d.ts 有三个来源,用途与维护者各不相同:

来源位置谁维护典型场景
包自带node_modules/<pkg>/dist/index.d.ts库作者现代 TS 项目的主流做法
@types 包node_modules/@types/<pkg>/index.d.tsDefinitelyTyped 社区纯 JS 老库(lodash、express 等)
项目手写仓库内任意位置,需被 include 覆盖你自己内部 SDK、无类型依赖、资源文件

包自带的类型通过 package.json 的 types 字段暴露:

{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

exports 里的 types 条件必须排在 import / require 之前,否则会被跳过。这一点在 11.3 npm 包、类型声明与 exports 里已经展开过。

用 tsc --declaration 可以让编译器为你的源码自动生成 .d.ts:

tsc --declaration --emitDeclarationOnly --outDir dist/types

--emitDeclarationOnly 表示只出类型、不出 .js(.js 交给 esbuild 之类的打包器)。对应的 tsconfig.json 配置是:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "outDir": "dist/types"
  }
}

declarationMap 会额外产出 .d.ts.map,让编辑器在「跳转到定义」时能直接落到 .ts 源码而不是声明文件,对调试体验提升很大。

脚本还是模块:声明文件的分水岭

这是 .d.ts 里最容易出错的一点:一个文件是「全局脚本」还是「模块」,取决于它有没有顶层 import / export。

没有 export 的声明文件,里面的所有声明都落在全局作用域:

// globals.d.ts —— 没有 export,是脚本
declare const APP_VERSION: string;

interface Window {
  __TRACKER__?: (event: string) => void;
}

上面的 APP_VERSION 会成为全局变量,Window 会与内置的 Window 合并(下一节细讲)。

而一旦出现顶层 import 或 export,文件就变成模块,所有声明都被关在模块作用域里:

// types/greet.d.ts —— 有 export,是模块
export declare function greet(name: string): string;

declare const INTERNAL_FLAG: boolean; // 模块私有,外部看不见

新手常见的翻车现场:想给某个库写模块声明,却在文件里写了 export {},结果原来的全局声明失效;或者反过来,想扩展全局却在文件里写了 import,导致 declare global 之外的内容全被私有化。

如果你确实需要在一个模块文件里扩展全局,就用 declare global 显式包起来——这是 12.3 模块扩充与全局类型增强 的主题:

export {}; // 让本文件成为模块

declare global {
  interface Window {
    __TRACKER__?: (event: string) => void;
  }
}

@types 的查找顺序

当一个包没有自带类型时,TypeScript 会去 node_modules/@types/ 里找同名包。完整顺序是:

  1. node_modules/<pkg>/package.json 的 types / typings 字段;
  2. node_modules/<pkg>/index.d.ts;
  3. node_modules/@types/<pkg>/(再走一遍上面的 1、2);
  4. 都找不到就报错。
TS7016: Could not find a declaration file for module 'lodash'.
  '/app/node_modules/lodash/lodash.js' implicitly has an 'any' type.
  Try `npm i --save-dev @types/lodash` if it exists or add a new declaration
  (.d.ts) file containing `declare module 'lodash';`

这条报错其实已经把三条出路都列出来了,照着做就行。注意最后那句 declare module 'lodash'; 是兜底方案:它把整个模块声明为 any,能消除报错但彻底放弃类型安全。它只应该当作临时手段。

安装 @types/node 后,process、Buffer、__dirname 这些 Node 内置全局就有了类型:

npm install --save-dev @types/node

typeRoots 与 types

@types 目录的行为由两个配置项控制:

{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./typings"],
    "types": ["node", "jest"]
  }
}
  • typeRoots:告诉编译器去哪几个目录找「类型包」。一旦显式指定,默认的 node_modules/@types 就不再自动包含,必须手动列进去——这是很多项目配置完发现 @types/node 失效的原因。
  • types:限制哪些包会被自动注入为全局类型。默认情况下 @types 下所有包都会被全局注入;设了 types 之后只有列出的包会被注入。

为什么要限制?因为自动注入是全局污染。装了 @types/jest 之后,全项目的 .ts 文件里 describe、it 突然都成了合法标识符,即使这个文件是浏览器代码。在大型 monorepo 里这类污染会直接引发同名冲突:

TS2451: Cannot redeclare block-scoped variable 'expect'.

对应策略就是 types 白名单,或者用项目引用把测试代码隔离到独立子项目(见 16.3 Monorepo 与 Project References )。

三斜线指令

老式声明文件里常见这样一行:

/// <reference types="node" />
/// <reference path="./legacy.d.ts" />
  • reference types:引入一个 @types 包,等价于把它加进 types 数组;
  • reference path:引入一个具体文件路径。

三斜线指令必须出现在文件最顶部,前面只能有注释。现代项目基本不用它——types 数组和正常的 import 已经能覆盖绝大多数场景,只有在写「必须零 import 的全局声明文件」时才需要。

一个最小可用的声明文件

假设公司内部有个老库 legacy-logger,只有 .js 没有类型,用法是:

const logger = require("legacy-logger");
logger.info("hello");
logger.error("boom", { retry: 3 });

在仓库里建 typings/legacy-logger.d.ts:

declare module "legacy-logger" {
  export interface LoggerOptions {
    retry?: number;
    silent?: boolean;
  }

  export function info(message: string): void;
  export function error(message: string, options?: LoggerOptions): void;

  const logger: {
    info: typeof info;
    error: typeof error;
  };
  export default logger;
}

再确保 tsconfig.json 的 include 覆盖 typings:

{
  "include": ["src/**/*", "typings/**/*"]
}

现在 import logger from "legacy-logger" 就有了补全,参数写错也会报错。

更复杂的场景——从源码反推签名、处理 export =、写通配声明——见 12.2 为无类型库编写声明 。

常见坑与错误信息

坑一:把声明文件写成 .ts。 文件名必须是 .d.ts。写成 globals.ts 之后,里面的 declare const APP_VERSION 会被当成「运行时真的需要提供这个变量」,构建时直接报未定义。

坑二:在 .d.ts 里写实现。

TS1183: An implementation cannot be declared in ambient contexts.

坑三:没装 @types/node 就到处 as any。 先装包,再谈其他。多数「Node 全局没类型」的问题都是漏装这一个包。

坑四:typeRoots 覆盖了默认值。 显式写 typeRoots 时一定要带上 ./node_modules/@types。

坑五:以为 .d.ts 能做运行时校验。 声明文件在编译后被完全丢弃,declare const 不会在运行时创建任何东西。要在运行时验证数据,需要 schema 方案,见 13.2 Zod 模式验证与类型推导 。

延伸阅读

小结

  • 类型在编译后会被完全擦除,.d.ts 是独立于 .js 的类型描述文件,不产出任何运行时代码。
  • declare 用于标记「有运行时实体但无实现」的声明;interface / type 这类纯类型不需要 declare。
  • .d.ts 的来源有三:包自带(package.json 的 types)、@types 社区包、项目手写。
  • 有无顶层 import / export 决定声明文件是全局脚本还是模块,这直接决定声明落在全局还是模块作用域。
  • 查找顺序是「包自带 → @types」,报错 TS7016 会直接给出三条出路;declare module "x"; 是放弃类型安全的兜底。
  • typeRoots 会覆盖默认值,types 用来限制全局注入,避免测试框架的全局类型污染业务代码。

下一节我们把这一节的手写声明做深:面对一个完全没有类型的库,如何从源码和文档反推出准确的签名。

阅读导航:上一节:11.3 npm 包、类型声明与 exports · 下一节:12.2 为无类型库编写声明 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes