声明文件与 .d.ts

掌握为无类型库编写声明文件、模块增强与全局类型扩展的方法,理解 TypeScript 类型声明的加载机制。

声明文件与 .d.ts

声明文件的角色

声明文件(.d.ts)是 TypeScript 类型系统与 JavaScript 生态之间的桥梁。它只包含类型信息,不包含运行时代码,告诉编译器某个 JavaScript 模块的形状(shape)。理解声明文件的编写和加载机制,是在大型项目中维护类型安全的关键能力。

面试定位:声明文件问题通常出现在"如何给没有类型的第三方库添加类型"或"如何扩展已有库的类型定义"这类实际工程场景中。


声明文件基础

声明语法

.d.ts 文件中使用 declare 关键字描述外部存在的值:

// globals.d.ts — 描述全局变量
declare const __DEV__: boolean;
declare const __APP_VERSION__: string;

// 描述全局函数
declare function ga(command: string, ...args: any[]): void;

// 描述全局对象
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: "development" | "production" | "test";
    DATABASE_URL: string;
  }
}

模块声明

// types/untyped-lib.d.ts
declare module "untyped-lib" {
  export function process(input: string): string;
  export interface Config {
    timeout: number;
    retries: number;
  }
  export default function init(config: Config): void;
}

通配符模块声明

处理非 JavaScript 导入:

// types/assets.d.ts
declare module "*.svg" {
  const content: React.FC<React.SVGProps<SVGSVGElement>>;
  export default content;
}

declare module "*.css" {
  const classes: Record<string, string>;
  export default classes;
}

declare module "*.png" {
  const src: string;
  export default src;
}

类型声明的加载机制

三种来源

TypeScript 从三个位置加载类型声明:

  1. 内置声明(lib):由 tsconfig.jsonlib 字段控制,如 "DOM", "ES2022"
  2. @types 包node_modules/@types/ 下的声明包,由 typestypeRoots 配置控制
  3. 项目内声明:项目中的 .d.ts 文件,由 include 配置控制

@types 与 DefinitelyTyped

DefinitelyTyped 是社区维护的类型声明仓库,通过 @types/xxx 包名发布:

npm install --save-dev @types/lodash
npm install --save-dev @types/node

自动加载规则

  • 默认情况下,node_modules/@types/ 下的所有包都会被自动加载
  • 通过 tsconfig.jsontypes 字段可以限制只加载指定的 @types
  • typeRoots 字段可以改变类型声明的搜索路径
{
  "compilerOptions": {
    "types": ["node", "jest"],
    "typeRoots": ["./types", "./node_modules/@types"]
  }
}

库自带类型 vs @types

现代库的趋势是自带类型声明(在 package.json 中指定 "types" 字段):

{
  "name": "my-lib",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

判断库是否有类型的优先级:

  1. 库自带 types 字段 → 直接使用
  2. 对应 @types/xxx 包存在 → 安装使用
  3. 两者都没有 → 需要手动编写声明文件

模块增强(Module Augmentation)

扩展第三方库的类型

// types/express-augmentation.d.ts
import "express";

declare module "express" {
  interface Request {
    user?: {
      id: string;
      role: "admin" | "user";
    };
    requestId: string;
  }
}

扩展 Window 对象

// types/global.d.ts
declare global {
  interface Window {
    __INITIAL_STATE__: Record<string, unknown>;
    gtag: (...args: any[]) => void;
  }
}

export {}; // 确保文件被视为模块

关键:包含 importexport 的文件是模块文件,全局声明必须包裹在 declare global {} 中。没有 import/export.d.ts 文件是全局脚本文件,声明直接生效。

扩展已有接口

// 扩展 React 的 CSSProperties
import "react";

declare module "react" {
  interface CSSProperties {
    "--primary-color"?: string;
    "--spacing"?: string;
    [key: `--${string}`]: string | undefined;
  }
}

全局类型扩展

环境变量类型

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: "development" | "production" | "test";
    DATABASE_URL: string;
    API_KEY: string;
    PORT?: string;
  }
}

全局工具类型

// types/utils.d.ts
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

为无类型库编写声明

完整声明

// types/legacy-analytics.d.ts
declare module "legacy-analytics" {
  interface AnalyticsConfig {
    trackingId: string;
    debug?: boolean;
    sampleRate?: number;
  }

  interface EventPayload {
    category: string;
    action: string;
    label?: string;
    value?: number;
  }

  export class Analytics {
    constructor(config: AnalyticsConfig);
    track(event: EventPayload): void;
    page(path: string): void;
    identify(userId: string, traits?: Record<string, unknown>): void;
  }

  export function createAnalytics(config: AnalyticsConfig): Analytics;
  export default createAnalytics;
}

渐进式声明策略

对于大型无类型库,可以渐进添加类型:

// 阶段一:最小可用声明
declare module "complex-lib";
// 所有导入类型为 any

// 阶段二:主要 API 声明
declare module "complex-lib" {
  export function mainApi(input: string): Promise<Result>;
  // 其余仍为 any
}

// 阶段三:完整声明
// 逐步补充所有导出的类型

声明文件的项目组织

推荐结构

project/
├── src/
├── types/
│   ├── env.d.ts           # 环境变量
│   ├── global.d.ts        # 全局类型扩展
│   ├── assets.d.ts        # 资源文件模块声明
│   └── legacy-lib.d.ts    # 无类型库声明
├── tsconfig.json

tsconfig 配置

{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./dist/types",
    "declarationMap": true
  },
  "include": ["src/**/*", "types/**/*"]
}
  • declaration: 编译时自动生成 .d.ts 文件
  • declarationMap: 生成声明文件的 source map,支持"跳转到源码"
  • declarationDir: 声明文件输出目录

面试高频问题

Q: 如何给一个没有类型定义的第三方库添加类型?

回答要点:三步走策略。首先检查 @types/xxx 包是否存在;若不存在,在项目的 types/ 目录下创建 xxx.d.ts,使用 declare module "xxx" 声明模块类型,并确保 tsconfig.jsoninclude 覆盖该目录。声明可以从最简单的 declare module "xxx" 开始(所有导入为 any),逐步完善具体 API 的类型签名。

Q: declare moduledeclare namespace 有什么区别?

回答要点declare module "xxx" 声明的是 ES module 或 CommonJS 模块的类型,对应 import xxx from "xxx" 的导入方式。declare namespace 声明的是全局命名空间,用于描述通过 <script> 标签加载的全局库,或者 Node.js 的全局对象(如 NodeJS 命名空间)。

Q: 为什么全局声明有时需要 export {}

回答要点:TypeScript 将没有 import/export 语句的文件视为全局脚本,其中的声明自动在全局作用域生效。一旦文件包含 importexport,就变成了模块文件,全局声明必须包裹在 declare global {} 中。添加空的 export {} 是将文件显式标记为模块的常见技巧。


延展阅读