编写具有类型声明的 TypeScript 库

1. 概述

TypeScript 库的用户需要两样东西:用于运行的 JavaScript 代码和类型声明文件(.d.ts)供编辑器提示,让编译器捕捉错误。如果缺少 .d.ts 或 package.json 指向错误的文件,用户会到处看到 any 类型或者遇到 Could not find a declaration file for module 错误。

本文将构建一个名为 order-pricing 的小型库,用于计算订单价格。读完后,你将学会如何配置 tsconfig.json 生成 .d.ts,在 package.json 中声明 exports,为旧 JavaScript 代码手写类型声明,以及在发布前同时测试运行时和类型。

2. 初始化项目

创建目录并安装必需的工具:

mkdir order-pricing && cd order-pricing
npm init -y
npm install --save-dev typescript vitest

typescript 只需放在 devDependencies 中。库的用户无需编译器,他们只需要已构建的 .js 和 .d.ts 文件。

2.1. 配置 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist",
    "isolatedDeclarations": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

关键选项的含义:

  • declaration: true 要求 tsc 在每个 .js 文件旁边生成 .d.ts 文件。这是本文的核心选项。
  • declarationMap: true 生成 .d.ts.map 文件。当用户点击“转到定义”时,编辑器将打开原始 .ts 文件而非 .d.ts 文件。要使此功能生效,你需要在发布时同时上传 src 目录。
  • module 和 moduleResolution 设为 NodeNext 使 TypeScript 以与 Node.js 完全相同的方式解析 module。以这种方式构建的库可在 bundler 和 Node.js 中使用。
  • isolatedDeclarations(从 TypeScript 5.5 开始)要求所有 export 的 API 必须显式声明其类型,例如函数的返回类型。这样,其他工具可以无需运行 type checker 即可生成 .d.ts。公共 API 也变得更加透明。
  • exclude 将 test 文件从构建中排除,防止 test 代码进入 dist。

2.2. 配置 package.json

{
  "name": "order-pricing",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "src"],
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "prepublishOnly": "npm run test && npm run build"
  }
}

需要注意的要点:

  • "type": "module" 让 dist 中的 .js 文件被 Node 视为 ESM。
  • 在 exports 中,条件 types 必须排在第一位。TypeScript 按顺序遍历条件并在第一个匹配条件处停止。如果 default 排在前面,TypeScript 永远不会读到 types。
  • 根级的 main 和 types 是不读取 exports 的旧工具的后备方案。
  • files 限制上传到 npm 的内容。缺少此字段,你容易不小心发布 node_modules 或内部配置文件。

3. 编写库代码

3.1. 定义数据类型

// src/types.ts
export type CustomerTier = "regular" | "silver" | "gold";

export interface Customer {
  id: string;
  name: string;
  tier: CustomerTier;
}

export interface OrderItem {
  sku: string;
  unitPrice: number;
  quantity: number;
}

export interface Order {
  id: string;
  customer: Customer;
  items: readonly OrderItem[];
}

items 声明为 readonly OrderItem[]。这样,用户传入普通数组或只读数组都是有效的。库也对外承诺不会修改用户的数组。

3.2. 价格计算逻辑

// src/pricing.ts
import type { CustomerTier, Order } from "./types.js";

const DISCOUNT_RATE: Record<CustomerTier, number> = {
  regular: 0,
  silver: 0.05,
  gold: 0.1,
};

/** 折扣前的商品总价,单位 VND。 */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** 根据客户等级应用折扣后的总价。 */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

在上面的代码中:

  • import type 告诉编译器这只是类型 import。构建后此行将从 .js 文件中完全删除。
  • import 路径写作 ./types.js 虽然源文件是 types.ts。使用 NodeNext 时这是必需的写法,因为 Node 会查找 .js 文件。
  • DISCOUNT_RATE 没有被 export,所以它是内部细节,不会出现在 .d.ts 中。
  • /** ... */ 形式的注释在 .d.ts 中保留。用户在编辑器中 hover 时会看到这些内容。

3.3. 入口点(Entry point)

// src/index.ts
export { calculateSubtotal, calculateTotal } from "./pricing.js";
export type { Customer, CustomerTier, Order, OrderItem } from "./types.js";

index.ts 决定了库的公共 API。你应该同时 export 数据类型。否则,用户只能写 Parameters<typeof calculateTotal>[0] 来获取 Order 类型。

4. 构建并查看 .d.ts 文件

npm run build

构建后的 dist 目录结构:

dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
├── index.js.map
├── pricing.d.ts
├── pricing.d.ts.map
├── pricing.js
├── pricing.js.map
├── types.d.ts
├── types.d.ts.map
├── types.js
└── types.js.map

dist/pricing.d.ts 的内容:

import type { Order } from "./types.js";
/** 折扣前的商品总价,单位 VND。 */
export declare function calculateSubtotal(order: Order): number;
/** 根据客户等级应用折扣后的总价。 */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

此文件仅包含签名(signature),不包含函数体。DISCOUNT_RATE 也不存在。dist/types.js 几乎为空(仅包含 export {};),因为 interface 和 type alias 在运行时不存在。

如果你从 calculateTotal 中去掉返回类型 : number,选项 isolatedDeclarations 会报错:

src/pricing.ts(20,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.

这个错误对你有利。如果返回类型是自动推断的,函数体中的一个小改变可能会无意中改变公共 API,而你却浑然不觉。

5. 运行时和类型检测

一个具有类型的库需要在两个方面进行测试:函数返回正确的值,且数据类型符合你的承诺。Vitest 支持两者。

// src/pricing.test.ts
import { describe, expect, expectTypeOf, it } from "vitest";
import { calculateSubtotal, calculateTotal, type Order } from "./index.js";

const goldOrder: Order = {
  id: "ORD-001",
  customer: { id: "C-01", name: "Lan", tier: "gold" },
  items: [
    { sku: "TEA-01", unitPrice: 150_000, quantity: 2 },
    { sku: "CUP-07", unitPrice: 200_000, quantity: 1 },
  ],
};

describe("calculateTotal", () => {
  it("计算折扣前的总价", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("对 gold 等级客户打 9 折", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("函数签名符合承诺的类型", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("不接受陌生的客户等级", () => {
    // @ts-expect-error "diamond" 不属于 CustomerTier
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

分析每个测试:

  • 前两个测试检查逻辑:2 × 150,000 + 200,000 = 500,000,打 9 折后为 450,000。
  • expectTypeOf 锁定函数的签名。如果之后某人把返回类型改为 string,此测试会失败。
  • @ts-expect-error 断言下面一行必须导致类型错误。如果某天 "diamond" 变得合法,TypeScript 会报告此指令是多余的。

运行测试:

npm test
 ✓ src/pricing.test.ts (4 tests) 3ms

 Test Files  1 passed (1)
      Tests  4 passed (4)

注意: 默认情况下 Vitest 只转译代码而不进行类型检查。@ts-expect-error 和类型错误仅在运行 tsc --noEmit -p tsconfig.test.json(包含 test 文件的单独 config)或 vitest --typecheck 时检查。你应该在 CI 中添加此步骤。

5.1. 发布前检查包

npm pack --dry-run
npm notice 📦  order-pricing@1.0.0
npm notice Tarball Contents
npm notice 1.1kB dist/index.d.ts
npm notice 312B  dist/index.js
...
npm notice 945B  src/pricing.ts
npm notice 402B  src/types.ts

你应该查看此列表以确保有所有 .d.ts 文件且没有 test 或机密文件。最实际的检查方法是运行 npm pack,然后在样本项目中安装 .tgz 文件并尝试导入。工具 @arethetypeswrong/cli(命令 attw --pack)也可以检测到大多数 exports 和 types 配置错误。

6. 为 JavaScript 代码手写声明文件

有时库仍包含尚未转换为 TypeScript 的旧 JavaScript 模块。你可以为该模块手写 .d.ts。

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * 向金额中添加增值税。
 * @param amount 税前金额(VND)。
 * @param rate 税率,默认 0.08。
 */
export declare function applyVat(amount: number, rate?: number): number;

然后像普通模块一样从 index.ts 重新 export:

export { applyVat } from "./legacy-tax.js";

注意: tsc 不会将手写的 .js 和 .d.ts 文件复制到 dist。你有两个选择。第一种是启用 allowJs: true 让 tsc 复制 legacy-tax.js 到 dist,然后通过脚本额外复制 legacy-tax.d.ts,例如 "build": "tsc && cp src/legacy-tax.d.ts dist/"。第二种是启用 allowJs 和 JSDoc,让 tsc 从 JSDoc 自动生成 .d.ts。这是一个非常常见的错误:本地运行正常,但发布后用户会遇到模块缺失错误。

如果你需要为没有类型的第三方 package 补充类型,请使用 declare module:

// src/shims/vn-currency.d.ts
declare module "vn-currency" {
  export function format(amount: number): string;
}

你应该仅在项目内部这样做。如果库的公共 API 依赖于此 shim,用户将看不到它。

7. 常见错误

  • types 在 exports 中 default 之后:TypeScript 不会读到 types,用户得到 any 或 TS7016 错误。
  • 使用 NodeNext 时忘记 .js 后缀:tsc 报 TS2835 错误。如果你通过改为 moduleResolution: "bundler" 来规避错误,库可能在 bundler 中工作但在纯 Node 中损坏。
  • 从 devDependencies 泄露类型:如果 Order 使用 devDependencies 中 package 的类型,.d.ts 文件将 import 该 package。用户没有安装此 package 所以会遇到类型错误。出现在公共 API 中的任何类型,其 package 必须在 dependencies 或 peerDependencies 中。
  • 依赖 skipLibCheck:此选项使你的构建更快,但也隐藏了你发布的 .d.ts 文件中的错误。在关闭 skipLibCheck 的样本项目中测试。
  • 随意导出 enum 或 namespace:const enum 对启用 isolatedModules 的用户造成麻烦。字符串联合如 CustomerTier 通常更简单且安全。
  • 在补丁版本中改变公共类型:在 Order 中添加必需字段是 breaking change,即使 JavaScript 代码没有改动一行。发布时应将 .d.ts 视为 API 的一部分。

什么时候不需要自己做所有这些步骤?如果库需要同时输出 ESM 和 CommonJS,使用专业的构建工具如 tsup 或 tsdown 而非两次配置 tsc。如果代码仅用于 monorepo 内部,TypeScript project references 通常比发布到 npm 更简洁。

8. 总结

本文从头构建了 order-pricing 库,主要步骤如下:

  • 在 tsconfig.json 中启用 declaration、declarationMap 和 isolatedDeclarations 生成清晰稳定的 .d.ts。
  • 在 package.json 中声明 exports,条件 types 排在第一位,并用 files 限制发布内容。
  • 使用 Vitest、expectTypeOf 和 @ts-expect-error 同时测试运行时和类型。
  • 为旧 JavaScript 代码手写 .d.ts 并记得将其包含在 dist 中。

最重要的规则是把 .d.ts 文件视为与用户的契约。在每次发布前用 npm pack 检查实际的包,因为许多配置错误只在用户端显现而不会在你的项目中出现。

typescriptnpmtype-declarationlibrarytsconfig