声明文件与 .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——

这些库没有类型,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 实战场景:三种都用过

公司项目里三种都用过:

四、声明文件实战

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 模式:

  1. 打开 allowJs
  2. 打开 checkJs
  3. 关键函数加 JSDoc 类型
  4. 新写的代码用 .ts 文件
  5. 老代码逐文件迁移

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 项目的编译配置调到最佳。

参考资料