TypeScript 声明文件编写指南:为没有类型的第三方库创建 .d.ts

本文介绍如何为没有类型定义的第三方 JavaScript 库编写 TypeScript 声明文件,从 declare module 基础语法到全局声明、UMD、export=、命名空间等常见形态,并总结最容易踩坑的地方与落地策略。这是一个实用的 .d.ts 编写指南。

很多 TypeScript 项目跑着跑着,就会在一个不起眼的地方迎面撞上一片红。你刚装了一个依赖,import 进来,编辑器立刻在模块名下面画了一条波浪线:Could not find a declaration file for module ‘xxx’。点进错误信息看,这个库在 npm 上活得好好的,文档齐全,就是没有一行 TypeScript 类型代码。

AI technology illustration

这时候大部分人会有几个本能反应:有人直接换成 any,有人试着在 tsconfig 里关掉 strict,还有人干脆把 noImplicitAny 关掉。这些做法都能让报错消失,但问题并没有真正解决。真正该做的是为这个库写一份 .d.ts 声明文件。这篇文章就围绕这件事展开:什么样的库需要自己写声明,声明文件的几种核心语法怎么用,以及哪些地方最容易写错。

先搞清楚 TypeScript 到底在要什么

先说清楚这个报错的本质。TypeScript 在编译时并不运行你的代码,只能靠类型信息来判断你的调用是否合法。对于自己写的 .ts 文件,类型信息来自源码;对于一个纯 JavaScript 的第三方库,TypeScript 需要一份描述文件——也就是声明文件(.d.ts)——来告诉它这个模块暴露了什么东西。

声明文件只有一个作用:描述类型。它不包含实现,不会生成任何运行时代码,编译后也不会出现在产物里。它更像是一张接口说明书,描述的是库的边界,而不是库的内部。

你可能会问:为什么不直接用 any?如果整个项目只有一处用到这个库,用 any 确实是成本最低的选择,很多团队的代码里也确实有不少 any。但当一个库被多个文件引用,或者它本身有复杂的配置项和回调参数,any 就会带来连锁隐患:函数签名丢失、重构时无法被检查、IDE 提示基本失效。更现实的情况是,团队里有新人接手时,一个没有类型的库就像一块黑箱,接错参数只能靠运行时报错来发现。为常用但没有类型的库补一份声明,本质上是在偿还技术债。

最基础的写法:declare module

新建一个后缀为 .d.ts 的文件,放在项目里能被 tsconfig 扫描到的位置(比如 src/types 目录),然后在里面声明模块。以 legacy-lib 为例:

// src/types/legacy-lib.d.ts
declare module 'legacy-lib' {
  export function format(input: string): string;

  export interface Options {
    verbose?: boolean;
    retries?: number;
  }

  export function run(options?: Options): Promise<void>;
}

这里的 declare module ‘legacy-lib’ 是核心。它告诉 TypeScript:当有人 import ‘legacy-lib’ 时,按我声明的东西来检查。模块名必须和 import 的路径完全一致,这是最容易出错的地方之一。

一个常见误区是把这个声明文件当成普通模块来写。如果在 .d.ts 文件里直接写 export function,外面 import 时依然找不到模块。必须用 declare module 把整个模块包起来,除非你写的是全局声明。

三种最常见的库形态

函数库:一个函数打天下

很多小型工具库实际上只导出一个函数,比如自定义的日期处理库。这种最简单:

declare module 'tiny-date-util' {
  export default function formatDate(
    input: string | Date,
    pattern?: string
  ): string;
}

注意这里用的是 export default。如果原库是用 CommonJS 的 module.exports 方式导出,TypeScript 里可能需要 export = 而不是 export default,这俩的互操作性一直是不少人的重灾区,后面专门说。

类库:描述构造过程和原型方法

如果一个库导出的是类,声明文件里可以用完整的类语法来描述:

declare module 'legacy-event-bus' {
  export class EventBus {
    constructor(options?: Record<string, unknown>);
    on(event: string, handler: (...args: unknown[]) => void): void;
    emit(event: string, ...payload: unknown[]): void;
  }
}

类声明里可以包含属性、方法、构造函数和静态成员,写法等价于正常 .ts 类的类型部分。

对象 + 命名空间:最常见也最啰嗦

老牌的库很喜欢把一堆函数挂在一个对象上,同时通过命名空间暴露类型。声明时可以用 namespace 配合模块导出:

declare module 'old-utils' {
  namespace oldUtils {
    function debounce(fn: Function, wait: number): Function;
    function throttle(fn: Function, wait: number): Function;
    interface Config {
      leading?: boolean;
      trailing?: boolean;
    }
  }
  export = oldUtils;
}

这里的关键点是 export =。它用于描述 CommonJS 风格的 module.exports = 导出,在老式库里面出现频率很高。把函数和 interface 放在 namespace 里再整体导出,是为了让使用者既能调用方法,又能拿到 Config 这样的类型。这种写法比较绕,但老库经常是这种形态,值得熟悉。

全局库和 UMD:不只是 import 的问题

不是所有库都需要 import。有些库的设计方式是直接往 window 上挂全局变量,比如一些旧版图表库或监控 SDK。这种情况下,声明文件要写在全局作用域里:

// src/types/global.d.ts
interface Window {
  __APP_VERSION__?: string;
  __LOADED_AT__?: number;
}

如果一个全局库是 UMD 形态,也就是既能用 script 标签引入,也能通过 import 使用,需要在声明里同时支持这两种用法。TypeScript 的惯例是使用 export as namespace:

declare module 'legacy-analytics' {
  export function track(event: string, payload?: object): void;
  export as namespace LegacyAnalytics;
}

declare global {
  interface Window {
    LegacyAnalytics: typeof import('legacy-analytics');
  }
}

最后一行用了 typeof import(),这是描述”某个全局变量的类型就是这个模块”的简洁方式。要提醒一句:在模块化的 .d.ts 文件里写 declare global 之前,必须保证文件至少有一个 import 或 export,否则整个文件会被当作全局文件处理。

最容易踩的四个坑

写声明文件最常见的困难不是语法不会,而是写了之后发现不生效,或者编译过了但类型根本没派上用场。归纳下来主要是这几类:

  1. 模块名对不上。import 用的是 ‘lib’,声明文件写的是 ‘lib/dist/index’。import 的路径和 declare module 的模块名必须完全一致,不确定时可以先用 import 试一次,看编辑器解析到了哪个路径。
  2. export default 和 export = 混用。CommonJS 库在 TypeScript 里可能被写成 import x from ‘lib’,但声明文件用了 export =,就会得到”没有默认导出”的报错。esModuleInterop 开启与否也会影响这里的行为,排查前先确认 tsconfig 里的设置。
  3. 忽略 skipLibCheck。如果你的声明文件引用了另一个库的类型,而那个库的类型本身有问题,编译时会报错。很多团队直接打开 skipLibCheck 跳过 .d.ts 的检查,这能省事,但也可能掩盖你自己声明文件里的类型错误。
  4. declare global 写错位置。全局声明放在既有 import 又有 export 的文件里,需要包在 declare global 里;放在没有任何 import/export 的文件里,则直接写在顶层。两个场景混用,就会出现全局声明不生效,或者报 Cannot find name 之类的错误。

自己写声明,还是找现成的?

在决定动手之前,值得花两分钟确认一下是不是真的需要自己写。处理第三方库类型问题的主流方式有三种,各有各的适用场景:

方案 适用场景 成本 注意点
使用 @types/xxx 库本身无类型,但 DefinitelyTyped 里有现成声明 低,一次安装即可 版本需与主库匹配,过度升级可能破坏类型兼容
项目内手写 .d.ts 库较冷门、版本私有,或声明需要贴合业务 中,需要持续维护 必须实际读源码接口,避免声明和实现脱节
any + 配置绕过 单次临时使用,或库已计划替换 最低 会污染类型检查,不应成为长期默认方案

很多团队的问题在于,一开始用了 any,后来库被越来越多模块引用,想再补声明时,已经没有人说得清这个库到底被用了哪些 API,只能边猜边写。如果你手里已经有大量 any 引用,切换成声明文件应该是一个渐进过程:先建立一份只包含最常用导出的最小声明,让 import 报错消失,再按使用频率一点点补充。

落地建议:从最小可用类型开始

写声明文件不需要一次写全。实际项目里,我一般建议按下面的顺序来做:

  1. 在 src/types 下建一个以库名命名的 .d.ts 文件,先声明模块名和最少量的导出,让报错消失,项目能编译。
  2. 翻一遍源码里对这个库的所有调用点,把真正用到的 API 找出来,补充签名。忽略那些只在文档里出现、项目里没用过的函数。
  3. 把声明文件纳入版本控制,并写进 README 或类型说明里,让后人在遇到同类问题时直接复用,而不是再写一个冲突的声明。

举一个具体例子。假设有一个老的处理 cookie 的库 cookie-kit,项目里只用了 get 和 set 两个函数:

// src/types/cookie-kit.d.ts
declare module 'cookie-kit' {
  export interface CookieOptions {
    path?: string;
    domain?: string;
    expires?: number | Date;
    secure?: boolean;
    sameSite?: 'strict' | 'lax' | 'none';
  }

  export function get(name: string): string | undefined;
  export function set(name: string, value: string, options?: CookieOptions): void;
}

这份声明大概十行,但已经足够让 TypeScript 正确检查项目里所有对 cookie-kit 的调用。以后用到 remove 函数,再往里面加一行就行。这种最小可用的做法,比一开始照着文档抄几十个函数要实际得多,因为文档里的签名和真实实现之间经常有出入。

另外值得养成一个习惯:声明文件更新后,至少跑一次 tsc –noEmit。声明文件里的错误不像业务代码那么容易被发现,很多问题只有在全量检查时才会暴露。

最后说几句

写声明文件这件事,本质上不是给 TypeScript 写代码,而是把你对库的理解固化下来。一份清晰的 .d.ts,对团队的价值不亚于一份精简文档;反过来,一个随便用 any 糊弄过去的地方,迟早会在某个发布前夜变成哑火的雷。

如果你所在的团队还没有处理过这类问题,我的建议很简单:下次再遇到 Could not find a declaration file,先别急着 any,试着写一份最小声明。哪怕只有三行 export function,你也已经从被类型系统卡住的人,变成了为类型系统铺路的人。前者的体验是阻塞,后者的体验是掌控感,这两者之间的差距,正是这份指南想帮你跨过去的东西。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/622/

(0)
上一篇 1小时前
下一篇 1小时前

相关推荐