TypeScript 对 JavaScript 的改造不只是加了类型标注,它还重新定义了类型信息的可见范围。很多人习惯按目录结构理解隔离,但 TypeScript 并不关心文件放在哪个目录,它只检查文件内容里有没有 import 或 export。只要没有这两个关键字,文件就属于全局脚本,里面的 interface、type、namespace 都会进入全局空间,所有文件都能直接使用。这种设计原本是为了方便给浏览器 API、第三方库或 Window 等全局对象补充类型,但项目变大之后,它很容易变成类型污染的主要来源。

全局脚本文件和模块文件之间的边界,在纯类型声明文件里尤其模糊。一个 .d.ts 文件里往往只写 interface 和 type,开发者不觉得需要 import 或 export,于是编译器就把它当作全局脚本。比如有一个专门用来补充 Window 属性的 global.d.ts,放在 types/ 目录下,但没有任何文件引用它。实际上它对整个项目都可见。如果两个业务团队各自写了 Window 的扩展,A 团队写了 __APP_VERSION__ 为 string,B 团队写了同名属性为 number,它们会在全局合并,最终编译要么报类型冲突,要么在某个文件里得到错误推断。要避免这种无意识的全局化,最简单的办法就是让每个声明文件都成为模块。在文件末尾加上 export {},或者让它包含任意的 import 或 export 语句,就能切断全局注入。这也是社区推荐的 .d.ts 文件写法。下面这个例子中,文件因为有了 export {},顶层的 interface 不会再进入全局作用域。
// 没有 import/export 的 .d.ts 会被视为全局脚本
interface AppConfig {
apiBase: string;
timeout: number;
}
上例中的 AppConfig 会全局可见。改成下面这样,它就不再影响全局。
// 通过 export {} 让文件变成模块
interface AppConfig {
apiBase: string;
timeout: number;
}
export {};
一、全局脚本:类型污染的起点
全局脚本的判定并不复杂:一个 TypeScript 文件只要不包含任何 import 或 export,它就是全局脚本。这个规则对 .ts 和 .d.ts 同样有效。很多人以为只有 .d.ts 才可能全局化,实际上业务 .ts 文件如果只写类型定义且没有导出语句,同样会成为全局脚本。例如在某个工具目录下新建一个 constants.ts,里面只写 interface,没有 export,那么整个项目都能直接使用这个 interface。这可能导致自动补全里出现很多你从未显式引入的类型。
全局声明的问题不在于它本身,而在于项目规模扩大后,多个文件开始无意识地合并到同一个全局命名空间。TypeScript 的 interface 会进行声明合并,所以两个文件分别声明同名 interface,属性会合并;如果同名属性类型不同,编译就会报错。这种报错有时不发生在全局声明文件里,而是发生在某个使用这些类型的业务文件里。于是开发者会看到修改 A 文件时 B 文件突然报错,而且报错点与修改点没有直接 import 关系,排查起来非常难受。
因此,在项目初期就统一规定所有 .d.ts 文件必须包含 export {} 或 import,是一个成本很低但收益很高的约束。它不会改变类型声明的表达能力,只是把类型从隐式全局共享变成显式模块导出,让依赖关系变得清晰可追踪。
二、declare global:模块里的合法后门
与全局脚本不同,declare global 是一种显式的全局扩展手段,它必须出现在模块文件里。一个文件如果已经包含 import 或 export,它就是模块,此时要在模块内部给全局 Window、Document 等对象增加类型,就必须使用 declare global。它的语法包裹在一个块中,块内声明的类型会被合并到全局作用域,而不是模块作用域。
// 在模块文件中显式扩展全局类型
export {};
declare global {
interface Window {
__APP_VERSION__: string;
track: (event: string) => void;
}
}
上面的文件因为包含 export {},本身是模块,但 declare global 又把两个属性注入到了全局 Window。这意味着任何一个文件都可以直接写 window.__APP_VERSION__,而不需要 import 这个类型。这种能力常用于应用启动阶段注入全局配置,或者开发第三方库时需要增强浏览器 API。但它的负面影响也很明显:如果多个第三方库都使用了 declare global 扩展同一个全局对象,比如 Window 或 Document,TypeScript 编译器会将这些声明合并。一旦属性名相同但类型签名不同,就会导致类型冲突。更麻烦的是,这种冲突可能只在你使用某个库的某个版本时出现,且不同文件报错位置不一致。
declare global 与全局脚本最重要的区别在于,前者明确写在模块内部,开发者通常知道自己在注入全局类型;后者则可能因为少写一个 export 而无意识地污染全局。因此,declare global 更像一个可控的、有意识的全局扩展入口,但仍需要谨慎使用,尤其是大型团队协作时,最好把它集中到少量专用文件中。
三、类型污染的典型症状与排查
类型作用域污染带来的问题通常不是直接报一个醒目的错误,而是表现为一些奇怪且关联性不强的症状。最常见的现象是,一个业务文件原本编译正常,在添加了一个看似无关的 .d.ts 文件后突然报错;或者编辑器的自动补全里出现大量从未手动引入的全局类型,让你误以为项目里存在隐式依赖。另一种现象是同一个全局对象上的同名属性在不同文件里被推断成不同类型,导致类型收窄失败或分支判断异常。
排查这类问题时,可以按照三个步骤进行。第一步,搜索项目里所有的 .d.ts 文件,检查它们是否包含 import 或 export。这一步可以直接定位出哪些文件是潜在的全局脚本。第二步,使用 TypeScript 提供的 tsc --listFiles 命令查看当前编译到底包含了哪些类型文件,尤其注意那些你没有显式引用却出现在列表中的 .d.ts 文件。第三步,检查 node_modules/@types 目录以及自己项目的 types 目录,看是否存在针对 Window、Document 等全局接口的 declare global 块。通常把范围收窄到这几个位置后,就能找到污染源。
需要特别明确的是,类型冲突和运行时错误是两回事。全局类型污染不会直接改变 JavaScript 运行时行为,它只影响编译期检查和编辑器提示。但正因为它不会导致运行失败,所以更容易被忽视,直到某一次重构或类型升级时才集中爆发。把它当作代码质量问题来管理,而不是等报错后再修,会更稳妥。
四、隔离类型作用域的实用做法
要避免类型作用域污染,最基础的一条规范是让所有 .d.ts 文件都成为模块。具体做法是在每个声明文件末尾添加 export {},或者通过 import type 引入依赖类型。这样做以后,顶层的 interface、type 只对显式导出和导入的文件可见,不会再全部混入全局空间。如果项目里已经有大量没有模块标记的声明文件,可以逐个加上 export {},然后通过 import type 调整引用关系,虽然开始时工作量略大,但一劳永逸。
对于确实需要扩展全局对象的需求,应该避免在业务文件中随意使用 declare global,而是单独维护一个 global.d.ts 或 window.d.ts,把全局增强集中起来。文件内需要对属性使用清晰的命名前缀,比如 __APP__,降低与第三方库或未来标准冲突的概率。还可以借助 interface 合并的特性,让不同模块只扩展自己关心的部分,但要保证同名属性不会重复定义为不同类型。
如果只是想给 Window 增加一些应用级配置,还可以不使用全局增强,而是定义自己的接口并显式导出,然后在使用时做一次断言。下面这种方式能保留类型检查,又不会污染全局 Window。
// types/app-window.d.ts
export interface AppWindow extends Window {
__APP_VERSION__: string;
__APP_ENV__: string;
}
// main.ts
import type { AppWindow } from './types/app-window';
const appWindow = window as AppWindow;
console.log(appWindow.__APP_VERSION__);
此外,tsconfig.json 中的 types 字段也能限制编译器自动包含哪些 @types 包。只显式列出项目真正需要的类型包,可以减少第三方全局增强带来的冲突面。结合这些措施,模块化之后的 TypeScript 项目既能享受类型共享的便利,也能把作用域污染控制在很小的范围内。
TypeScript类型系统模块化作用域污染修改时间:2026-10-05 17:01:02