第 17 章

声明文件:给 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 文件的位置:

  1. package.json 里的 typestypings 字段指定的路径
  2. 包根目录下的 index.d.ts
  3. 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 项目里,三斜线引用主要用在两种场景:

  1. 全局声明文件需要引用其他类型库时
  2. 库的 .d.ts 入口文件需要汇总多个子声明文件时

大多数情况下,直接用 tsconfig.jsontypes 字段或者 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 导入

判断用哪个的规则:


发布到 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.jsontypes/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:忽略边界情况——在使用本章介绍的特性时,注意处理 nullundefinednever 类型的边界情况。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 类型,消除这个根本性的不一致。

本章评分
4.9  / 5  (13 评分)

💬 留言讨论