模块、声明文件与第三方库

当你开始把 TypeScript 真正放进项目里,就会很快遇到一些不再是语法层面的现实问题:

  • 代码和类型应该如何跨文件组织
  • 第三方库没有类型时怎么办
  • 为什么有些包能直接提示类型,有些却报“找不到声明文件”
  • .d.ts 到底是什么,它和普通 .ts 文件有什么关系

这些问题不解决,TypeScript 很难真正变成工程工具。因为类型系统的价值,不只是写在单个文件里,更在于它如何穿过模块边界、跨越包依赖、与外部世界协作。

模块首先是 JavaScript 概念,其次才是 TypeScript 概念

这是一个特别值得先立住的认知。很多人学 TypeScript 时会把“模块系统”也当成 TS 独有内容,其实不是。现代 TypeScript 默认建立在 JavaScript 的 ES Module 体系上:

export interface User {
  id: number;
  name: string;
}

export function getUser(id: number): User {
  return { id, name: "Alice" };
}

在另一个文件中:

import { getUser } from "./user";

TypeScript 在这里做的,不是重新发明一套模块系统,而是在已有模块系统上增加类型理解能力。

为什么“模块边界”在 TypeScript 里格外重要

因为模块边界通常意味着这些问题:

  • 某个函数对外暴露了什么契约
  • 某个类型是否应该被外部消费
  • 某个模块内部实现细节是否不该泄露
  • 某个公共模型应该放在哪里,才能避免循环依赖和重复定义

你如果只会写单文件 TypeScript,离工程实践其实还差很远。真正的项目质量,很大程度上取决于模块边界是否清晰。

import type 是一个很值得养成的习惯

import type { User } from "./user";

这行代码的意思是:这里只导入类型,不导入运行时代码。

为什么这值得强调?因为它能帮助你在两个层面上变得更清楚:

  • 语义层面:这个导入只服务类型,不参与运行时逻辑
  • 工程层面:有助于工具链区分哪些依赖只存在于编译阶段

在大型项目里,import type 和普通 import 的区分,会让代码边界更清晰。

.d.ts 声明文件到底是什么

声明文件通常以 .d.ts 结尾。它的作用不是提供实现,而是告诉 TypeScript 某段运行时代码在类型层面长什么样。

例如:

declare module "my-lib" {
  export function format(value: string): string;
}

这里并没有真正实现 format,只是告诉编译器:“有这么一个模块,它导出了这样一个函数,请你以后按这个类型理解它。”

你可以把声明文件理解成“给类型系统看的说明书”。

为什么第三方库有时能直接用类型,有时不行

通常有三种来源:

  1. 库本身自带类型声明
  2. 社区提供 @types/xxx
  3. 你自己补 .d.ts

过去很多 JavaScript 库不自带类型,所以你需要安装类似:

npm install -D @types/lodash

但现在很多现代库已经直接内置类型,例如不少 React、Node.js、工具链生态里的主流包,都不再需要单独装 @types

如何判断一个库有没有自带类型

通常可以看:

  • 包的 package.json 是否包含 typestypings
  • 编辑器是否能直接识别类型
  • npm 页面或文档是否说明内置 TypeScript 支持

如果没有,那再考虑:

  • 是否存在 @types/xxx
  • 是否需要自己手写最小声明

手写最小声明,是很实用的工程技能

并不是只有做库开发的人才会碰 .d.ts。现实项目里,你经常会遇到:

  • 内部老模块没有类型
  • 一个很小的第三方包没人维护类型
  • 你临时接入了某个 JS 工具

这时你完全可以先写一个最小声明文件,满足当前使用需求:

declare module "legacy-lib" {
  export function parse(input: string): {
    code: number;
    message: string;
  };
}

你不一定一开始就要把所有 API 都写全。很多时候,只为当前真正使用到的部分补类型,就已经足够让工程质量提升一个层级。

声明文件的目标,不是绝对完整,而是逐步降低未知区域

这是一个很现实的工程视角。很多人看到 .d.ts 会紧张,好像必须一次性把整个库完整建模。其实不必。更实用的做法通常是:

  1. 先覆盖你当前用到的 API
  2. 尽量避免 any,但不要过度投入在一次性完美建模上
  3. 随着使用范围扩展,再逐步补全

这比一开始为了“完整性”花很多时间更符合项目现实。

模块增强和全局声明要谨慎使用

你还会遇到两类更进阶的声明能力。

模块增强

给已有模块补充额外类型:

declare module "my-lib" {
  interface Options {
    retry?: number;
  }
}

全局声明

window 或其他全局对象补字段:

declare global {
  interface Window {
    APP_VERSION: string;
  }
}

这些能力很强,但越强的能力越容易被滥用。全局污染越多,系统边界就越模糊。因此能局部声明时,尽量不要上升到全局。

为什么编辑器会提示“找不到模块声明”

这是很多人踩过的坑。通常不是 TypeScript 本身出了问题,而是下面这些环节之一没对上:

  • 包没装
  • 类型没装
  • 导入路径写错
  • tsconfig 的模块解析配置不匹配
  • 声明文件没被编译器包含
  • 库的导出方式和你的导入方式不匹配

也就是说,遇到这类问题时,优先排查工程配置和包结构,不要只盯着代码行本身。

一个工程上的好习惯:把“运行时代码”和“类型边界”一起设计

成熟的 TypeScript 项目不会把类型当作后补丁。它会在设计模块时一起考虑:

  • 对外暴露哪些类型
  • 哪些类型只在模块内部使用
  • 对第三方依赖的类型信任程度是多少
  • 是否需要对外部数据再做运行时校验

你越早把这些问题纳入设计,后面的模块关系就越稳。

本文小结

模块解决的是代码和能力的组织方式,声明文件解决的是“运行时存在、类型系统却不了解”的那部分鸿沟,而第三方库类型则是 TypeScript 工程实践中绕不开的日常工作。你不一定每天都写 .d.ts,但你必须理解它在整个系统中的位置:它是连接外部世界与类型系统的桥梁。

一旦你理解了这层关系,TypeScript 对你来说就不再只是单文件里的类型标注工具,而会真正变成一个可以跨模块、跨包、跨边界运行的工程系统。

练习

  1. 把一个接口和函数拆到单独模块中再导入,并尝试使用 import type 区分类型导入和普通导入。
  2. 为一个没有类型的假想库手写一个最小声明文件,只覆盖你会用到的 API。
  3. 在浏览器项目里为 window 增加一个自定义字段声明,并思考为什么全局扩展应该谨慎使用。

后记

2026年5月22日于上海。

更多推荐