文章

声明文件 .d.ts 编写与类型发布

面试向讲清 .d.ts:全局声明、模块声明、declare global 与模块增强怎么写,declaration 自动生成、package.json 的 types/exports 配置,以及 @types 发布路径。

声明文件 .d.ts 编写与类型发布

一句话概括

.d.ts 是“只有类型、没有实现”的说明书,编译后不产出任何 JS 代码。它存在的意义就一句话:让 TypeScript 认识那些它本来不认识的东西——没有类型的 JS 老库、图片/CSS 这类非 JS 资源、挂在 window 上的全局变量、process.env 里的自定义环境变量,以及你自己写的库要给别人用的 API 契约。

面试官问这个,通常不是让你背语法,而是三个实际问题:① 装了个 JS 库报 Could not find a declaration file for module 'xxx',你怎么处理?② 怎么给 window/process.env 加自定义字段?③ 你写的库怎么让使用者自动获得类型提示?把这三个场景答清楚,这题就过了。记住主线:.d.ts 只做描述不做实现;想扩展已有类型要用”模块增强”,而增强的前提是这个文件必须是个模块。

核心知识点

1. .d.ts 里能写什么:declare 与”必须 declare 或 export”

先记一条最容易踩的编译错误:在 .d.ts 里写顶层变量/函数/类,必须带 declare 或用 export 导出,否则直接报 “Top-level declarations in .d.ts files must start with either a ‘declare’ or ‘export’ modifier”。唯一例外是 type 和 interface——它们本来就是纯类型,不需要 declare。

1
2
3
4
5
6
// types/global.d.ts
type Env = "dev" | "prod";        // ✅ 纯类型,不用 declare
interface User { id: string }     // ✅ 同上

declare const __APP_VERSION__: string;   // ✅ 变量/常量必须 declare
declare function track(event: string): void; // ✅ 函数必须 declare

关键认知:declare 是在”声明一个别处已经存在的东西”,所以函数只有签名没有函数体。declare const 也是——你承诺运行时真有这么个全局常量(通常是构建工具注入的),TS 只是不去校验它的实现。这也是为什么 .d.ts 编译后啥也不产出:它压根不参与运行时。

2. 三种声明文件:全局脚本 / 模块声明 / 环境模块

区分点只有一个:文件里有没有顶层的 import 或 export。

类型判定作用域典型用途
全局声明文件(global script)没有任何顶层 import/export全项目可见,不用 importwindow.xxx、process.env、全局常量
模块声明文件有 export只在 import 它的地方生效给自己的库写 index.d.ts
环境模块声明全局脚本里写 declare module "xxx" {}全局生效给没有类型的第三方包补类型
1
2
3
4
5
// ✅ 全局脚本写法:给没类型的包补类型(注意:整个文件没有 import/export)
declare module "legacy-chart" {
  export function render(el: HTMLElement, data: number[]): void;
  export type Options = { theme: "light" | "dark" };
}
1
2
3
4
// ❌ 文件里一加 import,上面那句就从"环境模块声明"变成了"模块增强",
// 而 legacy-chart 本来就没类型,增强会失效甚至报错
import "legacy-chart";
declare module "legacy-chart" { /* ... */ }

一句话记忆:补类型用脚本文件(无 import),改类型用模块文件(有 import)。

3. declare global:给 window / process.env 加字段

这是面试最高频的一题。给 Window 加字段,正确写法是”模块文件 + declare global + export {}“三件套:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// types/global.d.ts
export {}; // 关键:让这个文件变成模块,否则 declare global 是非法的

declare global {
  interface Window {
    __APP_CONFIG__: { apiBase: string; featureFlags: Record<string, boolean> };
  }
  namespace NodeJS {
    interface ProcessEnv {
      readonly API_BASE: string;
      readonly NODE_ENV: "development" | "production" | "test";
    }
  }
}

为什么非要 export {}?因为只有在模块文件里才能用 declare global。不写 export {} 的话这个文件是全局脚本,里面的 interface Window 会变成”全局同名接口合并”——多数情况也能生效,但一旦有另一个包也在全局改 Window,两边声明会互相干扰、冲突难查。用 declare global 才是官方推荐的、可控的写法。

顺带一个必须说的边界:这只提供了编译期信心,不提供运行时保证。process.env.API_BASE 类型上是 string,但运行时它完全可能是 undefined。环境变量该在启动阶段校验还是得校验(配合 Zod 之类)。

4. 模块增强(Module Augmentation):扩展别人的类型

想给已有包加东西(比如给 Express 的 Request 挂 user),必须用模块增强,写法是先 import 再 declare module:

1
2
3
4
5
6
7
8
// types/express.d.ts
import "express"; // 或者 import { Request } from "express"

declare module "express" {
  interface Request {
    user?: { id: string; roles: string[] };
  }
}

这一步 import 是分水岭:有它 = 增强(与原有类型合并),没它 = 环境模块声明(把原有类型整个替换掉)。面试就爱问这句:“为什么我 declare module 之后原包的其他类型全没了?”——答:因为你漏了 import,你把人家的类型覆盖成 any 了。

Vue 项目里最典型的两个增强场景:

1
2
3
4
5
6
7
8
9
10
// ✅ 让 TS 认识 .vue 单文件组件(脚手架已内置,但面试常问原理)
declare module "*.vue" {
  import type { DefineComponent } from "vue";
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

// ✅ 认识静态资源
declare module "*.svg" { const src: string; export default src; }
declare module "*.css"; // 简短写法:整个模块视为 any(省事但丢失类型)

5. 自己产出 .d.ts:declaration 三兄弟

如果你的库是 TS 写的,不要手写 .d.ts,让编译器生成:

1
2
3
4
5
6
7
8
{
  "compilerOptions": {
    "declaration": true,          // 生成 .d.ts
    "declarationMap": true,       // 生成 .d.ts.map,点击类型能跳回源码
    "emitDeclarationOnly": false, // 只产出类型时用(例如打包器已负责 JS 产出)
    "stripInternal": true         // 带 @internal 注释的声明不出现在 .d.ts 里
  }
}

@internal 的用途很实在:内部工具类、调试方法不希望出现在公开的 API 契约里,就标上它:

1
2
/** @internal 仅供内部使用,不对外承诺 */
export function warmupCache() {}

6. 发布:package.json 里怎么指路

生成的 .d.ts 必须让使用者能找到。核心字段就几个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "name": "my-lib",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",   // typings 是它的历史别名,二者等价
  "files": ["dist"],              // 别忘了把 dist 打进 tarball
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",   // types 必须排在第一位!
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",  // CJS 用 .d.cts
        "default": "./dist/index.cjs"
      }
    }
  }
}

三个必背的点:

  1. 条件匹配是自上而下的,types 一定要写在 default/import 前面,否则 TS 会把 .js 当类型文件用,报一堆莫名其妙的错。
  2. 双格式包要用对扩展名:.d.ts 的解释方式取决于最近的 package.json 的 type 字段;ESM 用 .d.mts,CJS 用 .d.cts,别混。
  3. exports 优先于 typesVersions(TS 4.9 起的修正行为)。老写法靠 typesVersions 按 TS 版本分发声明:

    1
    
    "typesVersions": { ">=5.0": { "*": ["ts5.0/*"] } }  // TS≥5.0 去 ts5.0 目录找声明
    

还有一条工程红线:你的类型如果 import 了别的包的类型(比如 @types/express),那个包必须放 dependencies,不能放 devDependencies。因为使用者的 TS 也要能解析到它,而 npm 不会帮使用者装你的 devDependencies。

7. 别人没类型?两条路:@types 或自己补

  • 库自带类型(看 package.json 有没有 types 字段)→ 什么都不用装。先确认这一点再装 @types/xxx,自带类型和 @types 同时存在会冲突。
  • 库是纯 JS、 DefinitelyTyped 上有 → npm i -D @types/xxx。默认从 node_modules/@types 加载,可用 typeRoots 改目录、用 types 数组限定只加载哪几个(配 types: [] 可以彻底关掉自动加载,能加快大型项目检查速度)。
  • 都没有 → 自己在项目里写 declare module(第 2 节),或者去 DefinitelyTyped 提 PR 造福大家。

最后一个高频坑:skipLibCheck: true 会跳过所有 .d.ts 的检查,包括你自己写的。它几乎是所有项目的标配(编译快很多),但副作用是你 types/ 目录下的声明写错了也肉眼看不见。所以自己写的声明文件,最好偶尔关掉它跑一次 tsc --noEmit 验一遍。

其实你每天都在用

  • 安装 @types/node、@types/react —— 这就是 DefinitelyTyped 发布的声明包,坐在 node_modules/@types 里被自动加载。
  • Vite 项目里的 vite-env.d.ts,里面 /// <reference types="vite/client" /> 帮你认识了 import.meta.env 和 .svg/.css 资源。
  • import.meta.env.VITE_API_BASE 能自动补全,就是靠声明文件增强了 ImportMetaEnv 接口。
  • 给 window 挂埋点 SDK(window.sensors、window.gtag)时,不写声明就满屏红线。
  • 引入老牌 jQuery 插件、地图 SDK、支付 SDK 时那句 Could not find a declaration file for module 'xxx'。
  • 组件库(element-plus、antd)的 props 能自动提示、写错能报错——背后全是它们随包发布的 .d.ts。
  • 给后端接口响应写 interface ApiResponse<T>,本质上也是”描述一个运行时才有、编译期不存在的东西”。
  • 用 vue-tsc / tsc --noEmit 做 CI 类型门禁,检查的就是这些声明拼起来的类型网。

常见误解(FAQ)

❌ 误区1:”.d.ts 里可以写实现代码,反正会编译成 JS。” 不能。.d.ts 是纯类型空间,编译后零产出。写函数体会直接报错。它的角色是契约,不是实现。

❌ 误区2:”declare module 'xxx' 就是给这个模块补充类型。” 不一定,取决于文件里有没有 import。有 import = 增强(合并),没 import = 环境模块声明(替换)。后者会把原包的已有类型全部覆盖成你写的那点东西,漏 import 是最常见的翻车点。

❌ 误区3:”写了 interface Window 就行,不需要 export {}。” 能用,但不推荐。非模块文件里的 interface Window 是全局合并,容易和第三方包的同名增强打架。规范写法是模块文件 + declare global + export {}。

❌ 误区4:”类型声明包放 devDependencies 就行。” 只有当你的 .d.ts 对外暴露了别的包的类型时,那个包才必须是 dependencies。纯内部使用的类型可以放 devDependencies。判断标准:使用者的 TS 会不会在你的 .d.ts 里看到 import ... from "那个包"。

❌ 误区5:”process.env.API_BASE 类型是 string,所以它一定存在。” 类型只是编译期的承诺。环境变量运行时可能压根没注入,生产环境因此 undefined 崩掉的案例不要太多。类型给信心,启动校验给保障,两码事。

❌ 误区6:”skipLibCheck 开着,我的声明文件有错也会报出来。” 相反——skipLibCheck 会跳过全部 .d.ts 的检查,你手写的声明写错了也会被静默忽略。它是编译提速的利器,但也是类型错误的遮羞布。

❌ 误区7:”声明文件里的东西在运行时也会存在。” declare const __VERSION__: string 只是告诉 TS”别管了,运行时有”。如果构建工具没真的注入这个全局变量,运行时就是 ReferenceError。声明文件对运行时零作用力。

一句话总结

.d.ts 是给 TS 看的”接口说明书”——补类型用无 import 的环境模块声明,改类型用有 import 的模块增强;自己发库就开 declaration 自动生成、用 exports 里的 types 条件指路(记得排第一);别人没类型先看有没有自带,再装 @types,最后才自己写。

本文由作者按照 CC BY 4.0 进行授权