declare 是 TypeScript 用来告诉编译器”这个东西在运行时已经存在,你别再要求我写实现”的标记,它本身不产生任何 JavaScript 代码,编译时会被完整擦除。常见落地形态是 .d.ts 声明文件、declare module、declare namespace 与 declare global 四件套,覆盖了第三方无类型包、浏览器全局对象、Node 环境扩展与全局变量增强等几乎所有”在 TS 里使用非 TS 源码”的场景。
一、declare 的本质:只描述类型,不产生运行时代码
declare 修饰的所有声明都属于”环境声明(ambient declaration)”——告诉编译器某个变量、函数、类、命名空间或模块在别处已经存在,TS 只负责类型校验与提示,不负责实现。任何在 declare 上下文中写函数体、类方法实现或变量赋值,都会触发 “An implementation cannot be declared in ambient contexts” 报错。
// ❌ 不允许:declare 上下文中不能有实现
declare function sum(a: number, b: number): number {
return a + b;
}
// ✅ 正确:只声明签名
declare function sum(a: number, b: number): number;
这种”只承诺存在、不给出实现”的语义,是 TS 与原生 JS 生态互通的桥梁——你能在不重写老 JS 代码的前提下给它补齐类型。
二、四大常见用法速览
按使用频率从高到低排列,四种形态覆盖了日常 90% 以上的环境声明需求。
| 用法 | 形态 | 典型场景 |
|---|---|---|
| 全局变量声明 | declare const/let/var |
浏览器 script 标签注入的 $、jQuery |
| 全局函数/类 | declare function/class |
UMD 库挂到 window 上的工具方法 |
| 模块声明 | declare module 'name' |
没有自带类型的 npm 包、CommonJS 库 |
| 全局增强 | declare namespace / declare global |
扩展 Window、NodeJS.ProcessEnv、JSX |
如果只是引用第三方包且包没自带类型,首选一定是去 npm install @types/xxx;只有 @types 不存在或与业务类型不一致时,再手写 .d.ts。
三、.d.ts 声明文件怎么组织
.d.ts 文件只放类型描述,不放运行时代码,编译后不会出现在产物里。它相当于 TS 世界的”头文件”,告诉编译器”这玩意长啥样”。tsconfig.json 中 include、typeRoots、types 三个字段决定了哪些 .d.ts 会被自动加载。
// tsconfig.json
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"]
},
"include": ["src/**/*", "types/**/*"]
}
新建 types/jquery.d.ts,把第三方 jQuery 的全局 $ 补成可用类型:
// types/jquery.d.ts
declare const $: (selector: string) => {
show(): void;
hide(): void;
on(event: string, handler: (...args: any[]) => void): void;
};
之后在业务代码里直接用 $(...) 不会再报”找不到名称”。
四、declare module:给无类型 npm 包打补丁
项目里经常遇到 npm 拉下来的包没有 .d.ts,也没 @types/xxx 维护(比如某些内部包、老古董 CommonJS 模块)。这时用 declare module '包名' 给它一个最小可用类型壳。
// types/custom-lib.d.ts
declare module 'custom-lib' {
export function doSomething(input: string): number;
export const version: string;
export interface Options {
debug?: boolean;
timeout?: number;
}
export default function init(options?: Options): void;
}
// 业务代码
import init, { doSomething, version } from 'custom-lib';
init({ debug: true });
如果是更轻量的”先让 import 不报错就行”,可以省略内部签名,所有导入值退化为 any:
declare module 'untyped-legacy-pkg';
一个高频踩坑点:declare module 'x' 在没先 import 过同名模块时,会整体替换该模块的现有类型;如果只是想扩展类型,记得先写一行 import type * as X from 'x'; 再 declare 才会走”声明合并”分支。
五、declare namespace 与 declare global:扩展全局对象
declare namespace 适合描述”通过 script 标签加载的全局工具库”——比如 Google Maps SDK、Stripe.js、Analytics 脚本,调用时不需要 import,类型也得能在任意位置被识别。
// types/google-maps.d.ts
declare namespace googleMaps {
function initMap(containerId: string, lat: number, lng: number): void;
interface LatLngLiteral {
lat: number;
lng: number;
}
}
googleMaps.initMap('map', 39.9, 116.4);
declare global 则用于在”已经是模块的 .d.ts 文件里”扩展全局对象,常见于扩展浏览器 Window、自定义 JSX 元素、NodeJS.ProcessEnv 等。
// types/globals.d.ts
export {};
declare global {
interface Window {
__APP_VERSION__: string;
analytics: (event: string, payload?: Record<string, unknown>) => void;
}
}
注意文件首行 export {}——它把文件从”脚本”切换到”模块”模式,否则 declare global 不会生效,这是日常最容易写错的一处。
六、给环境变量补类型
Node/前端项目里 process.env.NODE_ENV、import.meta.env.VITE_API_BASE 这些变量 TS 默认认不出,必须显式声明。
// types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: 'development' | 'production' | 'test';
DATABASE_URL: string;
API_SECRET: string;
}
}
补完之后,编辑器会在 process.env 上自动补全、给出类型校验。注意这是编译期类型,运行时变量是否真存在还是要靠启动时校验库(如 zod、envalid)兜底。
七、模块声明合并 vs 替换:常被忽略的关键差异
declare module 'x' 看起来是”加一段类型”,但实际行为取决于该模块是否已经被 TS 识别。若同名模块尚未被 import 过,TS 会把整张类型表替换为你的声明——包原本自带的类型也会被一起丢掉,后续用到的所有类型都退化成你声明的形状。只有先 import type 或 import 一下该模块,再写 declare module,TS 才会走”声明合并”路径,把新增成员并入原表。这个差别在 monorepo 与第三方包扩展场景里非常容易踩到,遇到”明明装了 @types 却报一堆找不到属性”时,第一反应就应该是检查这个合并/替换分支。
八、避坑清单
新手用 declare 经常踩的几个坑,整理成检查表:
- 不要在 .ts 业务文件里到处写 declare——只在你”不拥有实现”的地方用,自己写的 TS 函数直接写实现就行。
- 不要在 declare 里写默认值——
declare function foo(x: number = 0)会报错,默认值是实现层的事。 - declare 不会出现在 JS 产物里——它只是类型层的承诺,别指望它”运行”任何东西。
- 模块声明前先 import 一次——否则会把包自带类型整个顶替掉。
- skipLibCheck: true 会静默吞掉 .d.ts 错误——配置时心里要有数,本地类型出错时记得临时关掉排错。
- 优先用 @types 包——只有社区没维护时才自己写 .d.ts,免得长期维护成本高。
常见问题(FAQ)
Q1:能不能在 .ts 业务文件里直接写 declare module?
能,但只对当前文件所在作用域生效;跨文件复用还是要放进 .d.ts 单独维护。
Q2:为什么 @types/xxx 已经装了却报错找不到类型?
常见原因有三种:tsconfig typeRoots 没覆盖 node_modules/@types、版本对不上、或者包自带类型与 @types 冲突,需要查重。
Q3:什么情况下 declare 声明会被合并,什么情况下会被替换?
未先 import 同名模块时整体替换;先 import 再 declare 走声明合并,全局接口(如 Window)天然合并。