TypeScript 声明文件编写指南:为无类型的 npm 包手写 .d.ts

为什么要手写声明文件

在 TypeScript 项目中引入一个没有类型定义的 npm 包时,编译器会抛出 TS7016 错误:“Could not find a declaration file for module 'xxx'”。这通常发生在以下几种场景:

  • 包本身用 JavaScript 编写,且作者没有发布 .d.ts 文件;
  • @types/xxx 包不存在或版本严重滞后;
  • 公司内部私有包,社区不可能提供类型定义。

面对这种情况,最省事的做法是在 tsconfig.json 中设置 "noImplicitAny": false,但这等于放弃了整个项目的类型安全。更负责任的方式是为该包手写一份声明文件,让类型系统重新为你工作。

声明文件的三种放置方式

在动手之前,先确定声明文件放在哪里。常见有三种策略:

第一种:src/types/ 目录 + typeRoots

在项目根目录创建 types/ 文件夹,将声明文件放在其中,并在 tsconfig.json 中配置:

{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./types"]
  }
}

这种方式适合声明文件需要被整个项目共享的场景。

第二种:与源码同目录

如果只是某个模块内部使用,可以在引用该包的文件旁边创建同名 .d.ts。TypeScript 会优先查找同目录下的声明文件。

第三种:declare module 通配声明

在任意 .d.ts 文件中写入:

declare module 'some-untyped-package';

这样该模块会被推断为 any 类型,虽然能消除报错,但没有任何类型信息,只适合临时过渡。

从零编写一个完整的声明文件

假设我们要为一个名为 string-kit 的字符串工具库编写类型声明。该库的 JavaScript 用法如下:

const stringKit = require('string-kit');

stringKit.trim('  hello  ');           // 'hello'
stringKit.capitalize('hello world');    // 'Hello world'
stringKit.truncate('hello world', 5);   // 'hello...'
stringKit.repeat('ab', 3);              // 'ababab'

第一步:确定模块导出形式

先看包的导出方式。如果是 module.exports = {...},对应 export = 语法;如果是 ESM 的 export default,对应 export defaultstring-kit 使用 CommonJS 导出,因此声明文件应写成:

declare module 'string-kit' {
  // 类型定义写在这里
}

第二步:为每个函数编写签名

declare module 'string-kit' {
  /**
   * 去除字符串首尾空白字符
   */
  export function trim(input: string): string;

  /**
   * 将字符串的首字母大写
   */
  export function capitalize(input: string): string;

  /**
   * 截断字符串并在末尾添加省略号
   * @param input 原始字符串
   * @param length 截断后的最大长度(不含省略号)
   * @param suffix 自定义后缀,默认为 '...'
   */
  export function truncate(
    input: string,
    length: number,
    suffix?: string
  ): string;

  /**
   * 重复字符串指定次数
   */
  export function repeat(input: string, count: number): string;
}

注意几个细节:可选参数用 ? 标记;JSDoc 注释会出现在编辑器的悬浮提示中,值得认真写;参数名尽量与原库文档保持一致。

第三步:处理默认导出与命名空间

如果库同时支持默认导出和命名导出,可以这样写:

declare module 'string-kit' {
  interface StringKit {
    trim(input: string): string;
    capitalize(input: string): string;
    truncate(input: string, length: number, suffix?: string): string;
    repeat(input: string, count: number): string;
  }

  const stringKit: StringKit;
  export default stringKit;
  export = stringKit;
}

export =export default 不能同时出现在同一个声明文件中,实际应根据包的 main 字段和 esModuleInterop 配置二选一。

进阶技巧

使用泛型增强类型推断

如果某个函数接受回调并返回其结果,泛型能让类型推断更精确:

export function map<T, U>(input: T[], fn: (item: T) => U): U[];

利用条件类型描述重载

当函数行为随参数类型变化时,条件类型比函数重载更简洁:

type Result<T> = T extends string ? string : number;
export function parse<T extends string | number>(input: T): Result<T>;

为对象参数定义接口

当函数接受配置对象时,单独抽出接口便于复用:

export interface TruncateOptions {
  length: number;
  suffix?: string;
  preserveWords?: boolean;
}

export function truncate(input: string, options: TruncateOptions): string;

验证与调试

写完声明文件后,用以下方式验证:

  1. 运行 tsc --noEmit,确认没有类型错误;
  2. 在编辑器中悬浮查看,确认参数提示和返回值类型正确显示;
  3. 故意传入错误类型,确认编译器能捕获;
  4. 检查 tsconfig.jsoninclude,确保声明文件所在目录被包含。

如果声明没有生效,常见原因是 typeRoots 配置覆盖了默认路径,或者声明文件没有放在 TypeScript 能自动发现的位置。

何时该考虑发布到 DefinitelyTyped

如果你为一个流行的开源包编写了完整的类型声明,不妨考虑提交到 DefinitelyTyped 仓库。这样其他开发者可以通过 npm install @types/xxx 直接使用你的成果。提交前需要确保:声明覆盖了包的全部公开 API、通过了 dtslinttsd 检查、有对应的测试用例。

对于内部私有包,更推荐直接在包源码中添加 .d.ts 文件并随包发布,而不是依赖使用方各自维护声明。

小结

手写 .d.ts 是 TypeScript 开发者的一项基础但重要的技能。核心步骤可以归纳为:确认模块导出形式 → 为每个 API 编写签名 → 用接口和泛型提升表达力 → 通过编译器和编辑器验证。一份好的声明文件不仅能消除红色波浪线,更能让无类型的 JavaScript 包在 TypeScript 项目中获得接近原生类型的开发体验。

未经允许不得转载:任鹏个人博客 » TypeScript 声明文件编写指南:为无类型的 npm 包手写 .d.ts

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏