为什么要手写声明文件
在 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 default。string-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;
验证与调试
写完声明文件后,用以下方式验证:
- 运行
tsc --noEmit,确认没有类型错误; - 在编辑器中悬浮查看,确认参数提示和返回值类型正确显示;
- 故意传入错误类型,确认编译器能捕获;
- 检查
tsconfig.json的include,确保声明文件所在目录被包含。
如果声明没有生效,常见原因是 typeRoots 配置覆盖了默认路径,或者声明文件没有放在 TypeScript 能自动发现的位置。
何时该考虑发布到 DefinitelyTyped
如果你为一个流行的开源包编写了完整的类型声明,不妨考虑提交到 DefinitelyTyped 仓库。这样其他开发者可以通过 npm install @types/xxx 直接使用你的成果。提交前需要确保:声明覆盖了包的全部公开 API、通过了 dtslint 或 tsd 检查、有对应的测试用例。
对于内部私有包,更推荐直接在包源码中添加 .d.ts 文件并随包发布,而不是依赖使用方各自维护声明。
小结
手写 .d.ts 是 TypeScript 开发者的一项基础但重要的技能。核心步骤可以归纳为:确认模块导出形式 → 为每个 API 编写签名 → 用接口和泛型提升表达力 → 通过编译器和编辑器验证。一份好的声明文件不仅能消除红色波浪线,更能让无类型的 JavaScript 包在 TypeScript 项目中获得接近原生类型的开发体验。
未经允许不得转载:任鹏个人博客 » TypeScript 声明文件编写指南:为无类型的 npm 包手写 .d.ts


朋友圈点赞图在线生成源码