本节目标:读完这一节,你能说清
.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.ts | DefinitelyTyped 社区 | 纯 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/ 里找同名包。完整顺序是:
node_modules/<pkg>/package.json的types/typings字段;node_modules/<pkg>/index.d.ts;node_modules/@types/<pkg>/(再走一遍上面的 1、2);- 都找不到就报错。
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 模式验证与类型推导
。
延伸阅读
- 项目级的类型配置与目录组织:/typescript-project-architecture-tsconfig/
- 把类型声明随 npm 包一起发布:/typescript-sdk-package-publishing/
- 编辑器如何消费声明文件(语言服务与插件):/typescript-language-service-editor-plugins/
- 类型优先的开发方式:/typescript-type-first-development/
小结
- 类型在编译后会被完全擦除,
.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 为无类型库编写声明 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。