声明文件与 .d.ts
声明文件与 .d.ts
写在前面
前面四篇讲了类型系统、联合类型、泛型、模板字面量,这一篇讲声明文件(.d.ts)。
声明文件是 TS 连接 JS 生态的桥梁——没有类型的老 JS 库,通过 .d.ts 也能享受类型检查。本篇会讲清楚怎么读、写、发布声明文件,以及实战给一个无类型 jQuery 插件补类型。
一、声明文件是什么
1.1 基础概念
.d.ts 文件是只包含类型声明、不包含实现的 TypeScript 文件。
// jquery.d.ts
declare const $: any;
export default $;
declare 关键字告诉 TypeScript:“这个变量/函数/类存在,但实现不在 TS 里”。
1.2 为什么需要声明文件
JS 生态里大量库没有 TypeScript——
- 老牌库(jQuery、Lodash 老版本、很多 jQuery 插件)
- 内部库(公司自己写的 JS)
- 第三方库的某个子模块
这些库没有类型,TypeScript 用了就报错,或者变成 any。
声明文件就是给 JS 库补类型的解决方案。
1.3 声明文件的格式
// types/foo.d.ts
declare module "foo" {
export function bar(x: string): number;
export const baz: number;
export default function qux(): void;
}
declare module "模块名"——声明模块的类型。
二、内置类型声明
2.1 lib.d.ts
TypeScript 自带所有 JS 标准 API 的类型声明:
// TypeScript 内置
const arr = [1, 2, 3];
arr.map(x => x.toFixed(2)); // 知道 map 和 toFixed 的类型
const date = new Date();
date.toISOString(); // 知道 Date 的所有方法
localStorage.getItem("foo"); // 知道 localStorage 类型
这些都来自 lib.d.ts 和它的拆分文件(lib.es5.d.ts、lib.dom.d.ts 等)。
2.2 配置使用哪些内置类型
// tsconfig.json
{
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
}
}
按需引入——不写 DOM 就没有浏览器 API 类型(Node 项目)。
三、为已有 JS 库添加类型
3.1 三种方式
| 方式 | 适用 |
|---|---|
| DefinitelyTyped | 公开库,贡献到社区 |
| 自己的 .d.ts | 内部库,或 DefinitelyTyped 没有的 |
| JSDoc + checkJs | 不改文件,直接在 JS 里加 JSDoc |
3.2 实战场景:三种都用过
公司项目里三种都用过:
- 公开 npm 包用 DefinitelyTyped(
@types/lodash、@types/react) - 内部库自己写
.d.ts - 老 JS 模块用 JSDoc
四、声明文件实战
4.1 简单库类型声明
假设有个老 JS 库:
// my-utils.js
export function add(a, b) {
return a + b;
}
export function formatDate(date, format = "YYYY-MM-DD") {
// ...
}
export class EventBus {
constructor() {
this.listeners = {};
}
on(event, listener) {
// ...
}
emit(event, ...args) {
// ...
}
}
写类型声明:
// types/my-utils.d.ts
export function add(a: number, b: number): number;
export function formatDate(date: Date, format?: string): string;
export class EventBus {
constructor();
on(event: string, listener: (...args: any[]) => void): void;
emit(event: string, ...args: any[]): void;
}
类型声明必须和实现兼容——类型"夸大"会导致运行时错误。
4.2 全局变量声明
老 JS 库经常往 window 上挂全局变量:
// analytics.js
window.Analytics = {
track(event, properties) { ... },
identify(userId, traits) { ... },
};
类型声明:
// types/analytics.d.ts
declare global {
interface Window {
Analytics: {
track(event: string, properties?: Record<string, any>): void;
identify(userId: string, traits?: Record<string, any>): void;
};
}
}
export {};
注意 declare global + export {}——让它成为模块(避免污染全局)。
4.3 模块扩展
扩展已有模块的类型:
// types/express.d.ts
import "express";
declare module "express" {
interface Request {
user?: {
id: string;
email: string;
};
}
}
declare module "express" + interface Request 会合并到 express 的 Request 上。
实战:Express middleware 给 req 挂 user 后,后续所有 req.user 都有类型。
4.4 实战:为 jQuery 插件补类型
// 第三方插件 datatable.js
$.fn.dataTable = function(options) {
// ...
return this; // 返回 this 以支持链式
};
$.fn.dataTable.defaults = {
pageLength: 10,
searching: true,
};
类型声明:
// types/datatables.d.ts
interface DataTableOptions {
pageLength?: number;
searching?: boolean;
ordering?: boolean;
columns?: Array<{
data: string;
title?: string;
}>;
ajax?: string | ((data: any, callback: (data: any) => void) => void);
}
interface DataTableInstance {
page(len: number): DataTableInstance;
search(query: string): DataTableInstance;
destroy(): void;
column(idx: number): {
visible(vis: boolean): DataTableInstance;
};
}
declare global {
interface JQuery {
dataTable(options?: DataTableOptions): DataTableInstance;
}
namespace DataTableDefaults {
interface Defaults {
pageLength: number;
searching: boolean;
}
}
const DataTableDefaults: DataTableDefaults.Defaults;
}
export {};
类型安全地使用插件:
$("#myTable").dataTable({ pageLength: 25 }); // OK
$("#myTable").dataTable({ pageLen: 25 }); // 报错 - 拼写错误
$("#myTable").dataTable().page(2).search("foo"); // 类型安全
五、DefinitelyTyped
5.1 什么是 DefinitelyTyped
DefinitelyTyped 是一个开源仓库,里面有几乎所有主流 JS 库的类型声明:
https://github.com/DefinitelyTyped/DefinitelyTyped
5.2 安装 @types 包
npm install --save-dev @types/lodash
npm install --save-dev @types/react
npm install --save-dev @types/node
@types/xxx 包会自动被 TypeScript 找到。
5.3 怎么找 @types 包
# npm search
npm search @types/jquery
# 或者去 https://www.npmjs.com/~types 看所有
5.4 贡献自己的类型
如果 @types/xxx 没有,可以贡献。
# 1. fork DefinitelyTyped 仓库
git clone https://github.com/DefinitelyTyped/DefinitelyTyped
# 2. 创建 types/xxx 目录
mkdir types/jquery-datatable
cd types/jquery-datatable
# 3. 创建 index.d.ts
# 把你的类型放进来
# 4. 添加测试 types/xxx/__tests__/xxx-tests.ts
# 必须有测试,否则 PR 不会被合并
# 5. 创建 package.json
{
"name": "@types/jquery-datatable",
"version": "1.0.0",
"description": "Type definitions for jquery-datatable",
"license": "MIT",
"private": true,
"dependencies": {
"@types/jquery": "*"
}
}
# 6. 提交 PR
测试文件必须有:
// types/jquery-datatable/__tests__/jquery-datatable-tests.ts
import "jquery-datatable";
$("#test").dataTable({ pageLength: 25 });
tsc 跑过就证明类型有效。
六、JSDoc + checkJs
6.1 不改文件也能类型化
如果不想改 JS 文件为 TS,可以用 JSDoc + checkJs:
// user.js
/**
* @typedef {Object} User
* @property {string} id
* @property {string} name
* @property {number} age
*/
/**
* @param {string} id
* @returns {Promise<User>}
*/
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// tsconfig.json
{
"compilerOptions": {
"checkJs": true,
"allowJs": true
}
}
TS 会从 JSDoc 推断类型——不需要改文件后缀。
6.2 JSDoc 类型注解
/**
* @param {string | number} id
* @param {Object} [options]
* @param {number} [options.pageSize=10]
* @returns {Promise<User[]>}
*/
async function fetchUsers(id, options = {}) {
// ...
}
/**
* @template T
* @param {T[]} arr
* @returns {T | undefined}
*/
function first(arr) { return arr[0]; }
6.3 实战:迁移老 JS 项目
公司有个老 JS 模块 200 个文件,没人力改成 TS。我们用 checkJs + JSDoc 模式:
- 打开
allowJs - 打开
checkJs - 关键函数加 JSDoc 类型
- 新写的代码用 .ts 文件
- 老代码逐文件迁移
5 个月迁移完成,没一行代码回归。
七、声明文件的高级用法
7.1 模块声明合并
// types/express.d.ts
import "express";
declare module "express" {
interface Request {
user?: AuthUser;
}
interface Response {
success<T>(data: T): void;
error(message: string, code?: number): void;
}
}
// 用法
app.get("/profile", (req, res) => {
req.user; // 类型是 AuthUser | undefined
// 自定义方法
res.success({ name: "Alice" });
});
7.2 全局类型扩展
// types/array.d.ts
declare global {
interface Array<T> {
groupBy<K extends string>(fn: (item: T) => K): Record<K, T[]>;
}
}
export {};
Array.prototype.groupBy = function(fn) {
return this.reduce((acc, item) => {
const key = fn(item);
(acc[key] = acc[key] || []).push(item);
return acc;
}, {});
};
7.3 条件声明
// 只在某些条件下声明类型
declare module "my-lib" {
interface Options {
/** @default true */
cache?: boolean;
}
export function init(options?: Options): Promise<void>;
}
7.4 索引签名
declare module "i18n" {
type Resources = {
[key: string]: string | Resources;
};
export const t: (key: string, params?: Record<string, string>) => string;
export function setLocale(locale: string): void;
}
八、声明文件的常见错误
8.1 错误 1:重复声明
// 不能两个 .d.ts 都声明同一个
// types/a.d.ts
declare module "foo" { ... }
// types/b.d.ts
declare module "foo" { ... }
// 报错:Duplicate identifier
8.2 错误 2:export 写法错
// NG - 在 .d.ts 里 export 一个 const
export const x: number = 5; // .d.ts 里不应该有运行时代码
// OK
export declare const x: number; // 只声明,不实现
8.3 错误 3:类型对不上实现
// 实现
export function add(a, b) { return a + b; }
// 声明(类型夸大)
export function add(a: string, b: string): string; // 类型说返回 string,但实际可能是 number
// 实际调用会运行时出错
声明必须保守——宁愿类型"窄"一点,不要"宽"。
九、实战:公司内部 SDK 类型化
9.1 背景
公司有个内部 SDK(10 个 JS 文件),被 50 个项目引用。没有类型。
9.2 类型声明文件
// types/internal-sdk.d.ts
declare module "@company/sdk" {
export interface SDKConfig {
apiKey: string;
endpoint: string;
timeout?: number;
retries?: number;
}
export interface User {
id: string;
name: string;
email: string;
}
export class SDK {
constructor(config: SDKConfig);
users: {
get(id: string): Promise<User>;
list(opts?: { limit?: number; offset?: number }): Promise<User[]>;
create(data: Omit<User, "id">): Promise<User>;
update(id: string, data: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
};
orders: {
get(id: string): Promise<Order>;
list(opts?: { userId?: string }): Promise<Order[]>;
};
track(event: string, properties?: Record<string, unknown>): void;
}
export default SDK;
}
9.3 配套文档
// SDK README.md
// ## 类型
// 类型在 types/internal-sdk.d.ts
// 引用: import SDK from "@company/sdk";
// const sdk = new SDK({ apiKey, endpoint });
让所有引用方都能用自动补全——比写文档有用 10 倍。
十、发布带类型的 JS 库
10.1 三种发布方式
方式 1:库本身就是 TS
// package.json
{
"main": "dist/index.js",
"types": "dist/index.d.ts" // 自动有类型
}
方式 2:库是 JS,自带 .d.ts
// package.json
{
"main": "dist/index.js",
"types": "dist/index.d.ts" // 自带的 .d.ts
}
方式 3:发布到 DefinitelyTyped
不需要在库里带类型,用户装 @types/xxx。
10.2 实战:我们的库发布流程
公司 SDK 库:
// package.json
{
"name": "@company/sdk",
"version": "1.2.3",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist", "README.md"],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "^5.3.0"
}
}
prepublishOnly 确保发布前一定构建——不会有"发布失败但代码进去了"的情况。
十一、本篇小结
| 概念 | 关键点 |
|---|---|
.d.ts |
只包含类型声明,不包含实现 |
declare module |
声明外部模块的类型 |
declare global |
扩展全局变量 |
| DefinitelyTyped | 社区维护的 @types/* 包 |
| JSDoc + checkJs | 不改文件也能类型化 |
| 模块声明合并 | declare module "x" 合并接口 |
| 实战 | 应用 |
|---|---|
| jQuery 插件补类型 | declare global interface JQuery |
| Express 扩展 req.user | 模块声明合并 |
| 内部 SDK 类型化 | 单个 .d.ts 描述所有 API |
| 老 JS 迁移 | JSDoc + checkJs |
下一篇讲 tsconfig 调优与工程配置——把 TS 项目的编译配置调到最佳。