TypeScript 声明文件与互操作

TypeScript 最大的工程难题不是”怎么写类型”,而是**“如何让已有 JS 生态在类型系统下工作”**。声明文件(.d.ts)和互操作机制要解决的就是这个问题。

对应 Python 的 stub 文件(.pyi——为没有类型标注的代码补上类型信息。但 TS 的声明文件远比 .pyi 复杂,因为它要覆盖 JS 的各种”动态魔术”:原型链扩展、this 类型、函数重载、全局变量污染——这些 JS 独有的模式在 Python 的类型世界中没有对应物。


.d.ts 的核心角色

是什么

.d.ts 文件是 TypeScript 的”类型声明文件”——只包含类型信息,不包含运行时逻辑。编译器用它们来类型检查,但从不生成对应的 JS 代码。

源码层面:
  user.ts       ← 既有类型又有逻辑
  user.d.ts     ← 只有类型声明,无逻辑

编译后:
  user.ts → user.js  (类型擦除,保留逻辑)
  user.d.ts → (不输出 JS)

三种来源

来源示例场景
源码自带lodash 自带 index.d.ts库作者用 TS 写
社区补充npm install @types/lodashJS 库没有类型 → DefinitelyTyped
项目自写src/types/global.d.ts针对项目特定的 JS 代码/全局变量

DefinitelyTyped:全球最大的类型协作仓库

什么是 DefinitelyTyped

@types/* 包来自 DefinitelyTyped——一个由社区维护的独立于库作者的类型声明仓库

# 安装 lodash 的类型声明(lodash 本身是 JS 库)
pnpm add lodash
pnpm add -D @types/lodash
 
# 现在 lodash 在 TS 中有完整的类型提示
import _ from "lodash";
_.chunk([1, 2, 3, 4], 2); // 类型:number[][]

工作原理

你的代码:
  import express from "express"
                ↓
TypeScript 编译器查找类型:
  1. express 包内有 index.d.ts 吗? → 没有(express 是 JS 库)
  2. @types/express 存在吗? → 有 → 使用它的类型
                ↓
express 在编译后有完整类型支持

常见问题与对策

问题表象对策
库没有 @typesCould not find declaration file自建 types/foo.d.ts + declare module
@types 版本不匹配类型报错但运行时正常锁定 @types 版本,或 fork 修复
@types 质量差大量 any,无实际约束tsconfig 中启用 skipLibCheck
库已内建类型但 @types 冲突重复声明报错移除 @types/*,用内建类型
// 自建声明文件 types/my-js-lib.d.ts
declare module "my-js-lib" {
  export function doWork(input: string): Promise<number>;
  export interface Options {
    timeout: number;
    retry: boolean;
  }
}
 
// 现在可以在代码中正常引用
import { doWork } from "my-js-lib";

常用声明技巧

declare module:为无类型模块补类型

// 为 .css/.scss 模块声明类型(Vite 常见需求)
declare module "*.css" {
  const content: Record<string, string>;
  export default content;
}
 
// 为全局变量声明类型
declare global {
  interface Window {
    __INITIAL_STATE__: Record<string, unknown>;
    analytics: {
      track(event: string, data?: unknown): void;
    };
  }
}
 
// 扩展已有模块的类型
declare module "express" {
  interface Request {
    user?: { id: string; role: string };
  }
}

declare namespace:为全局库声明类型

// 模拟 jQuery 的全局声明
declare namespace $ {
  function ajax(url: string, options?: AjaxOptions): void;
  interface AjaxOptions {
    method?: "GET" | "POST";
    data?: Record<string, unknown>;
  }
}
 
// 使用
$.ajax("/api/users", { method: "GET" });

ambient declarations:环境声明

// 声明一个运行时才存在的变量(如 CDN 引入的脚本)
declare const APP_VERSION: string;
declare function trackPageView(page: string): void;
 
// 编译器相信它们在运行时存在,不做静态验证
console.log(APP_VERSION);
trackPageView("/home");

JS 与 TS 的互操作策略

从 JS 迁移到 TS:渐进式路线

TS 的一大卖点是渐进式采用——你不需要一次性把所有 .js 改成 .ts

策略 1(推荐):先 `allowJs: true`,逐步改名 .js → .ts
策略 2:保留核心 JS 文件,新增文件用 TS
策略 3:JS 文件用 .d.ts 补类型,TS 文件直接类型化
// tsconfig.json — 渐进迁移的核心配置
{
  "compilerOptions": {
    "allowJs": true,            // 允许 .js 文件参与编译
    "checkJs": false,           // 不检查 .js 文件的类型
    "outDir": "dist"
  },
  "include": ["src/**/*"]
}

TS 调用 JS:最平滑的方向

// legacy-utils.js — 旧的 JS 工具函数
function formatMoney(amount, currency) {
  return `${currency}${amount.toFixed(2)}`;
}
module.exports = { formatMoney };
// types/legacy-utils.d.ts — 为 JS 补充类型
declare module "legacy-utils" {
  export function formatMoney(amount: number, currency: string): string;
}
 
// app.ts — TS 代码调用 JS,有类型保护
import { formatMoney } from "legacy-utils";
const price = formatMoney(19.99, "$"); // 类型安全
// formatMoney("19.99");              // 编译错误

JS 调用 TS:需要构建产物

// new-feature.ts — 新写的 TS 代码
export function processData(input: string): { id: string; data: unknown } {
  return { id: crypto.randomUUID(), data: JSON.parse(input) };
}
// old-app.js — 旧 JS 代码引用编译后的 JS
const { processData } = require("./dist/new-feature.js");
processData('{"key": "value"}'); // 没有类型检查,但能正常运行

与 Python 类型存根的对比

维度TS .d.tsPython .pyi
仓库DefinitelyTyped(社区统一)typeshed(CPython 标准库)
第三方库类型@types/foo 独立安装库自带的 py.typed 标记或独立 stub 包
模块声明declare module "foo" 功能强大*.pyi 仅声明接口
全局声明declare global / declare namespace无直接等价能力
生态覆盖极高(几乎所有流行 JS 库都有 @types)中等(许多库仍未提供类型)
语法复杂度高(需覆盖 prototype、this、overloads 等 JS 动态模式)低(Python 的动态行为相对有界)

TS 的声明文件系统是为了将动态的、无类型的 JS 世界纳入静态类型系统而设计的,所以它必须处理大量 JS 独有的模式——原型链扩展、this 类型、函数重载、全局变量污染等。Python 的 .pyi 更多是”已有接口的静态表达”,不需要覆盖这些 JS 特有的难题。


常见声明陷阱

1. 函数重载声明

// 错误 — 声明签名与实际签名混在一起
function greet(name: string): string;
function greet(name: string, title: string): string; // 参数数量不同
 
// 正确 — 声明签名在上,实现签名在下(参数取并集)
function greet(name: string): string;
function greet(name: string, title: string): string;
function greet(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}

2. export default vs export =

// CJS 模块 (module.exports = xxx) 的声明
declare module "legacy-lib" {
  export = LegacyLib; // 对应 module.exports
}
declare function LegacyLib(config: any): void;
 
// ESM 模块 (export default) 的声明
declare module "modern-lib" {
  export default function modernFunc(input: string): void;
}
 
// 混用会导致 import 方式不匹配

3. 命名空间与模块的混淆

// 在模块文件(有 import/export)中用 declare namespace
declare namespace MyLib { }
 
// 在全局声明文件(无 import/export)中用 declare namespace
// 或在模块文件中用 declare module
declare module "my-lib" { }

关联