声明文件:给 JS 库手写 .d.ts,发布 @types
第17章:声明文件:给 JS 库手写 .d.ts,发布 @types
理解声明文件是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用给 JS 库手写 .d.ts,发布 @types?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 声明文件是什么
- 全局声明
- 模块声明
Level 1 · 你需要知道的(1-3年经验)
声明文件是什么
.d.ts 文件是纯类型文件——里面只有类型声明,没有任何运行时代码,不会被编译进最终的 JavaScript 输出。它的作用是告诉 TypeScript 某个 JavaScript 模块或全局变量的类型形状,让你在使用它时获得类型检查和自动补全。
// math-utils.js(JavaScript 库,无类型)
function add(a, b) { return a + b; }
function clamp(value, min, max) { return Math.min(Math.max(value, min), max); }
module.exports = { add, clamp };
// math-utils.d.ts(对应的声明文件)
export declare function add(a: number, b: number): number;
export declare function clamp(value: number, min: number, max: number): number;
TypeScript 编译时查找 .d.ts 文件的位置:
package.json里的types或typings字段指定的路径- 包根目录下的
index.d.ts node_modules/@types/包名/index.d.ts(DefinitelyTyped 发布的类型)
全局声明
当一个 JS 库通过 <script> 标签注入全局变量时,用全局声明告诉 TypeScript 这些全局存在。
// globals.d.ts
// 声明全局变量
declare var __DEV__: boolean;
declare var __VERSION__: string;
// 声明全局函数
declare function log(message: string, level?: "info" | "warn" | "error"): void;
declare function require(module: string): unknown;
// 声明全局类
declare class EventEmitter {
on(event: string, listener: (...args: unknown[]) => void): this;
off(event: string, listener: (...args: unknown[]) => void): this;
emit(event: string, ...args: unknown[]): boolean;
}
// 声明全局命名空间(适合有子属性的全局对象)
declare namespace MyApp {
interface Config {
apiUrl: string;
timeout: number;
}
function init(config: Config): void;
function destroy(): void;
}
使用:
// 不需要 import,直接用
if (__DEV__) {
log("Debug mode active", "info");
}
MyApp.init({ apiUrl: "https://api.example.com", timeout: 5000 });
模块声明
为一个 JavaScript 模块写类型,用 declare module "模块名" 语法。
基本模块声明
// 为 "lodash" 写声明(简化示例,实际 @types/lodash 复杂得多)
declare module "lodash" {
export function chunk<T>(array: T[], size?: number): T[][];
export function flatten<T>(array: (T | T[])[]): T[];
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait?: number
): T;
// 默认导出
const _: {
chunk: typeof chunk;
flatten: typeof flatten;
debounce: typeof debounce;
};
export default _;
}
为通配符模块写声明
处理 CSS modules、图片文件、SVG 等非 JS 资源:
// 让 TypeScript 接受 .css 模块导入
declare module "*.css" {
const styles: { [className: string]: string };
export default styles;
}
// SVG 作为 React 组件导入(Vite 的 ?component 语法)
declare module "*.svg?react" {
import React from "react";
const SVGComponent: React.FC<React.SVGProps<SVGSVGElement>>;
export default SVGComponent;
}
// 普通图片资源
declare module "*.png" {
const src: string;
export default src;
}
declare module "*.json" {
const value: unknown;
export default value;
}
扩充已有类型(Module Augmentation)
你不需要替换已有的类型声明,可以向它们添加字段。这是给框架对象挂载自定义属性的标准方式。
扩充 Express 的 Request 对象
// src/types/express.d.ts
import "express";
declare module "express" {
interface Request {
user?: {
id: string;
email: string;
roles: string[];
};
requestId?: string;
startTime?: number;
}
}
之后在中间件里使用:
// middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "./jwt";
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "Unauthorized" });
req.user = verifyToken(token); // 类型正确,不需要 as any
next();
}
扩充第三方类型的全局命名空间
// 为 Webpack 的 NodeRequire 添加 context 方法
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
PORT?: string;
}
}
Level 2 · 它是怎么运行的(3-5年经验)
三斜线引用
在声明文件里引用其他类型依赖,用 /// <reference .../> 语法。
// 引用类型包
/// <reference types="node" />
/// <reference types="jest" />
// 引用另一个声明文件
/// <reference path="./globals.d.ts" />
现代 TypeScript 项目里,三斜线引用主要用在两种场景:
- 全局声明文件需要引用其他类型库时
- 库的
.d.ts入口文件需要汇总多个子声明文件时
大多数情况下,直接用 tsconfig.json 的 types 字段或者 import 语句就够了,不需要三斜线引用。
实战:为真实 JS 库手写声明文件
以一个简单的工具库 string-utils 为例,逐步写出完整的声明文件。
库的 JavaScript 代码(string-utils/index.js):
"use strict";
// 截断字符串,在末尾加省略号
function truncate(str, maxLength, ellipsis) {
ellipsis = ellipsis || "...";
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
}
// 把字符串转为 slug 格式
function slugify(str) {
return str
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// 模板字符串替换
function template(str, vars) {
return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
return vars[key] !== undefined ? String(vars[key]) : "";
});
}
// 主命名空间导出
module.exports = { truncate, slugify, template };
module.exports.truncate = truncate;
module.exports.slugify = slugify;
module.exports.template = template;
第一步:分析导出结构
这个库用 CommonJS module.exports 导出,既有命名导出又可以整体导入。
第二步:写基本函数签名
// string-utils/index.d.ts(初稿)
export declare function truncate(
str: string,
maxLength: number,
ellipsis?: string
): string;
export declare function slugify(str: string): string;
export declare function template(
str: string,
vars: Record<string, string | number | boolean>
): string;
第三步:处理 CommonJS 兼容性
因为这个库用 module.exports,TypeScript 在 CommonJS 项目里需要特殊处理:
// string-utils/index.d.ts(完整版)
interface StringUtils {
truncate(str: string, maxLength: number, ellipsis?: string): string;
slugify(str: string): string;
template(str: string, vars: Record<string, string | number | boolean>): string;
}
// 支持两种导入方式:
// import { truncate } from "string-utils"
export declare function truncate(str: string, maxLength: number, ellipsis?: string): string;
export declare function slugify(str: string): string;
export declare function template(str: string, vars: Record<string, string | number | boolean>): string;
// 支持:
// import stringUtils from "string-utils"
// const { truncate } = require("string-utils")
declare const stringUtils: StringUtils;
export = stringUtils; // 注意:export = 和 export {} 不能同时用
等等——export = 和具名 export 不能混用。需要选择一种:
// 方案 A:用 export =(适合纯 CommonJS 库,使用时需要 esModuleInterop: true)
interface StringUtils {
truncate(str: string, maxLength: number, ellipsis?: string): string;
slugify(str: string): string;
template(str: string, vars: Record<string, string | number | boolean>): string;
}
export = StringUtils;
// 使用时:
// import StringUtils = require("string-utils");
// 或者(esModuleInterop 开启时):
// import StringUtils from "string-utils";
// 方案 B:用具名 export(适合支持 ESM 的现代库)
export declare function truncate(str: string, maxLength: number, ellipsis?: string): string;
export declare function slugify(str: string): string;
export declare function template(str: string, vars: Record<string, string | number | boolean>): string;
第四步:写 package.json 里的类型指向
{
"name": "string-utils",
"version": "1.0.0",
"main": "index.js",
"types": "index.d.ts"
}
export = vs export default
这是声明文件里最容易混淆的地方。
// export = :CommonJS 风格,等价于 module.exports = xxx
// 声明文件
declare function createServer(options: ServerOptions): Server;
export = createServer;
// 使用
import createServer = require("my-server"); // 标准写法
import createServer from "my-server"; // 需要 esModuleInterop: true
// export default:ES Module 风格
// 声明文件
declare function createServer(options: ServerOptions): Server;
export default createServer;
// 使用
import createServer from "my-server"; // 正常 ESM 导入
判断用哪个的规则:
- 库用
module.exports = xxx:用export = - 库用
export default xxx:用export default - 库同时用
module.exports = xxx并且module.exports.foo = bar:用export =配合接口声明
发布到 DefinitelyTyped
如果你用的库没有类型,可以给它写声明文件并发布到 DefinitelyTyped,让所有人通过 @types/xxx 安装使用。
目录结构:
DefinitelyTyped/
└── types/
└── string-utils/
├── index.d.ts # 主声明文件
├── index.d.ts.map # source map(可选)
├── package.json # 包元数据
└── tsconfig.json # 用于测试
types/string-utils/package.json:
{
"private": true,
"name": "@types/string-utils",
"version": "1.0",
"description": "TypeScript definitions for string-utils",
"license": "MIT",
"main": "",
"types": "index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
"directory": "types/string-utils"
}
}
测试声明文件(types/string-utils/tsconfig.json):
{
"compilerOptions": {
"module": "commonjs",
"lib": ["es6"],
"noImplicitAny": true,
"noImplicitThis": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"types": [],
"noEmit": true,
"strict": true
},
"files": ["index.d.ts"]
}
用 dtslint 验证(DefinitelyTyped 官方测试工具):
# 安装
npm install -g dtslint
# 在类型目录下运行测试
dtslint types/string-utils
在声明文件里可以写类型测试注释:
// index.d.ts
export declare function truncate(str: string, maxLength: number): string;
// $ExpectType string
truncate("hello world", 5);
// $ExpectError
truncate(123, 5); // 参数类型错误,应该报错
typesVersions:为不同 TypeScript 版本提供不同类型
当你的库需要兼容老版本 TypeScript,但也想给新版本提供更精确的类型:
{
"name": "my-library",
"version": "2.0.0",
"types": "./dist/index.d.ts",
"typesVersions": {
">=4.7": {
"*": ["./dist/v4.7/*"]
},
">=4.0": {
"*": ["./dist/v4.0/*"]
}
}
}
TypeScript 会根据当前编译器版本选择对应的类型文件。
@types/xxx vs 库自带类型
| 情况 | 来源 | 说明 |
|---|---|---|
package.json 里有 types 字段 |
库自带 | 无需额外安装 |
安装 @types/xxx |
DefinitelyTyped | 社区维护,版本可能滞后 |
| 两者都有 | 库自带优先 | 可以用 types tsconfig 选项覆盖 |
判断顺序:TypeScript 先查 package.json 的 types/typings,再查 @types,再查目录下的 index.d.ts。
声明文件常见错误
错误 1:在 .d.ts 里写实现代码
// 错误:.d.ts 只能有类型,不能有实现
export declare function add(a: number, b: number): number {
return a + b; // SyntaxError:声明文件不能有函数体
}
// 正确
export declare function add(a: number, b: number): number;
错误 2:忘记 declare 关键字
// 错误:在声明文件的全局作用域里缺少 declare
var globalVar: string; // 这会被当作值声明,但 .d.ts 不能有值
// 正确
declare var globalVar: string;
错误 3:模块扩充时没有导入原模块
// 错误:没有 import,这会创建新的模块而不是扩充现有模块
declare module "express" {
interface Request {
user?: User;
}
}
// 正确:必须先 import 原模块(即使只是空导入)
import "express";
declare module "express" {
interface Request {
user?: User;
}
}
错误 4:全局声明和模块声明混在一个文件
// 如果文件里有 import 或 export,它就变成了模块文件
// 全局声明在模块文件里不生效
// 错误写法(文件里有 import,全局声明不生效)
import { SomeType } from "./types";
declare var myGlobal: string; // 这不是全局的
// 正确:用 declare global 包装
import { SomeType } from "./types";
declare global {
var myGlobal: string;
}
Level 3 · 规范怎么定义的(资深)
声明文件(.d.ts)是 TypeScript 与 JavaScript 生态系统的桥梁。declare 关键字告诉编译器"这个值在运行时存在,但不在当前文件中定义"。模块扩充(Module Augmentation)利用了 interface 的声明合并特性——在导入原模块后再次声明同名 interface,新声明的属性会被合并到已有类型中。export = 对应 CommonJS 的 module.exports =,export default 对应 ESM 的默认导出——两者不能混用是 TypeScript 对模块系统差异的忠实反映。
Level 4 · 边界与陷阱(所有人)
常见陷阱 1:忽略边界情况——在使用本章介绍的特性时,注意处理 null、undefined 和 never 类型的边界情况。TypeScript 的类型系统在这些边界类型上有特殊行为,需要额外注意。
常见陷阱 2:过度使用导致可读性下降——本章的高级特性应该在确实需要时才使用。如果简单的泛型或联合类型能解决问题,不要引入复杂的类型级编程。代码是写给人读的,类型也是。
汇总表
| 语法 | 用途 | 示例 |
|---|---|---|
declare var/let/const |
全局变量 | declare var __DEV__: boolean |
declare function |
全局函数 | declare function fetch(url: string): Promise<Response> |
declare class |
全局类 | declare class EventEmitter { ... } |
declare namespace |
全局命名空间 | declare namespace MyApp { ... } |
declare module "..." |
模块类型 | declare module "lodash" { ... } |
declare module "*.png" |
通配符模块 | CSS Modules、图片资源 |
export = |
CommonJS 默认导出 | module.exports = xxx 对应 |
export default |
ESM 默认导出 | export default xxx 对应 |
declare global { } |
在模块文件里写全局声明 | 扩充 global 作用域 |
下一章预告
第 18 章讲运行时验证:TypeScript 的类型只在编译期存在,但 API 响应、用户输入、环境变量等数据在运行时才知道实际类型。zod 库让你定义一个 schema,同时获得运行时验证逻辑和 TypeScript 类型,消除这个根本性的不一致。