在编辑器里对 useState 或 z.object 悬停,会弹出参数和返回类型;按住 Ctrl(Windows)或 Command(macOS)再点击,常常会跳进一份 .d.ts,而不是正在跑的那份 .js。这和「Node / 打包器如何找到运行时文件」不是同一条路。
下文把编辑期这条线称为类型契约:谁在提供智能提示,类型文件从哪来,以及它和运行时入口如何分叉。
核心区分: 悬浮和跳转读的是类型表面;浏览器或 Node 执行的是另一份实现。两边可以对齐,也可以故意分开。
先分清三个名字
日常口语里「TS 智能提示」常被揉成一团。拆开更清楚。其中 LSP 是 Language Server Protocol(语言服务器协议) 的缩写:编辑器不自己「懂」每一种语言,而是通过这套协议向背后的语言服务提问。
| 名称 | 是什么 | 本文关心的边界 |
|---|---|---|
| LSP(Language Server Protocol) | 编辑器与语言服务之间的协议 | 规定「补全、跳转、诊断」这类请求怎么往来;不规定某个 npm 包长什么样 |
| tsserver | TypeScript 自带的语言服务实现 | 很多编辑器已经内置 ;打开 .ts / .js 时,提示往往来自它,而不必单独再装一个「TS 语言包」 |
.d.ts |
声明文件,描述类型表面 | 通常不参与运行时;给检查器和编辑器读 |
TypeScript 手册把 Declaration Files 定位为「描述已有 JavaScript 的形状」。可以记成:.d.ts 描述类型,通常不负责运行时执行。
三者不是并列的三个工具,而是自上而下叠在一起的分层:界面发起请求 → 协议约定怎么问 → 语言服务负责算 → 声明文件(或带类型的源码)提供材料。
用一张职责表对照同一条栈:
| 层 | 角色 | 典型对象 |
|---|---|---|
| 界面层 | 发起请求、展示结果 | VS Code 等编辑器 UI |
| 协议层 | 规定「怎么问、怎么答」 | LSP |
| 服务层 | 真正做补全、跳转、诊断 | tsserver(常内置);Vue 场景下也可能是 Volar 等 |
| 材料层 | 被服务读入的类型表面 | .d.ts / .d.cts 等;有时是可解析的 .ts 源码 |
本节重点: LSP 是协议层,tsserver 是服务层,
.d.ts是材料层。 缺协议则编辑器挂不上服务;缺服务则没人读声明;缺材料则服务无类型可推断。换 Vue 扩展时,动的是服务层,不是换一套 LSP 语法。
tsserver 常常已经在编辑器里
以 VS Code 为例:安装完成后就带有 TypeScript / JavaScript 语言支持,背后用的正是 tsserver (或与其同族的语言服务)。所以写普通 .ts 时,悬浮和跳转往往无需再装别的扩展------并不等于机器上额外装过一个叫 tsserver 的独立软件,它经常是编辑器内置能力的一部分。
其他编辑器或插件市场里的 TypeScript 支持,思路类似:由某个语言服务进程回答 LSP 请求;只是发行方式不同(内置、扩展或项目本地的 typescript 包)。
框架文件会打破「只靠内置 tsserver」的默认假设,但要和 JSX / TSX 分开看。.jsx / .tsx 仍是 TypeScript(及 JS 语言服务)认识的语法:文件整体还是「一份脚本」,标签会按 JSX 变换规则参与类型检查,所以内置 tsserver 就能提示、报错------不必再装一个「JSX 语言服务器」。
Vue 的单文件组件(.vue)不一样:同一文件里叠了模板、脚本、样式等块,不是 TypeScript 默认的单一脚本文件模型。内置服务看不懂整份 .vue 的结构,所以生态里长期需要专门的 Vue 语言扩展:早期常见 Vetur,后来主流转向 Volar(现多以官方 Vue 扩展的形式出现)。装扩展之后,又容易和编辑器自带的 TypeScript 支持同时处理同一批文件:
- 两个服务同时管
.ts/ 脚本块 → 重复提示、错误标记互相冲突、跳转不稳定; - 文档里曾推广过「接管模式」(Takeover Mode)一类做法:在工作区禁用内置 TypeScript 扩展,让 Vue 扩展统一提供语言服务;
- 新版本扩展也可能改为挂 TypeScript 插件、与内置服务协作,具体以当前官方说明为准。
实用印象: 提示异常时,先问「此刻是谁在当语言服务」 ------内置 tsserver、Vue 扩展,还是两者叠在一起。这和某个 npm 包的
types字段写错,是不同层的问题。
还要和另一条命令分开:项目里跑的 tsc(或打包器)负责编译与出包;编辑期持续回答问题的是语言服务。二者都懂 TypeScript,但开关和职责不同。声明文件如何被构建出来,可另文再写;本文只谈装进 node_modules 之后,编辑器怎么接上类型。
现象:悬浮与跳转到定义
以 Zod 为例(写法随大版本会变,结构足够说明问题):
ts
import { z } from 'zod'
const User = z.object({
name: z.string(),
age: z.number().optional(),
})
type User = z.infer<typeof User>
在支持 TypeScript 语言服务的编辑器里,通常可以观察到:
- 悬停在
z.object上,能看到参数与返回类型相关的提示。 - 跳转到定义时,往往进入包内的声明文件(扩展名可能是
.d.ts,也可能是.d.cts等变体),而不是去「执行」那份实现文件。 - 若类型与实现分文件存放,跳转结果和运行时解析到的文件可以不是同一个路径。
易错点: 跳进声明文件,不等于运行时也会加载这份文件。它只说明语言服务在用这份声明做类型推断。
类型从哪读:有没有「标准顺序」
有,但标准在 TypeScript 的模块解析 里,不在 npm 安装算法里。Node 跑 import 时按自己的 exports / main 选实现文件 ;tsserver / tsc 在解析同一个包名时,会再走一套「找类型」的规则。两套规则会看同一张 package.json,选中的文件可以不同。
官方把「和实现打在同一个 npm 包里」与「另发到 @types」写成两种发布方式,见 TypeScript 的 Publishing declaration files。消费侧可以记成下面这条主路径(细节随 moduleResolution 略有差异,见后文):
用文字把同一条路径说死:
- 先看这个包自己有没有交出类型。
- 现代包:在
exports里为该入口配置types条件 (与import/require/default并列)。TypeScript 文档建议把types写在条件对象里靠前的位置,避免旧工具按「对象键顺序」误伤。 - 兼容写法:顶层
types(与历史字段typings同义)指向根声明文件。 - 还有
typesVersions:按 TypeScript 版本把声明重定向到不同目录(少见,排错时知道有这层即可)。
- 现代包:在
- 再试实现文件旁边的声明文件。
例如解析到dist/index.js时,会找同目录的dist/index.d.ts。这就是「.js与.d.ts放一起」的常见形态,不一定非要再写一遍顶层types。 - 包自己始终交不出类型时,才轮到
@types/*。
导入lodash若包内无可用声明,再去解析@types/lodash。
若包已经自带类型,一般不会再去用同名的@types覆盖它。
标准结论: 顺序是「包自带(exports.types / types / 旁路 .d.ts)→ 再
@types」。 不是 npm 另立的第四种安装协议,而是 TypeScript 解析器的约定。
两种常见发布方式:打进包,或交给 @types
| 方式 | 磁盘上长什么样 | 谁维护版本 |
|---|---|---|
| 自带类型 | 与 dist/*.js 一起发布 *.d.ts / *.d.cts,并用 types / exports.types 指过去 |
与实现同一 npm 包、同一版号(如 zod、nanoid) |
| DefinitelyTyped | 实现包几乎无声明;另装 @types/包名 |
@types/* 可与实现包不同步发版 |
TypeScript 的建议很直白:类型若能由源码生成,就和实现发在一起;否则再考虑提到 DefinitelyTyped。消费时:
- 自带类型:通常不必 再装
@types/该包;强行装了,也优先信包自己的声明。 - 纯 JS 老包:才需要
@types/xxx;提示正常只说明声明文件找得到,不保证与实现主版本锁步。
对照:zod(类型与实现分叉)
撰稿时 zod 根入口大致是这种形态(字段会随版本变化,以 npm view zod exports 为准):
json
{
"type": "module",
"types": "./index.d.cts",
"exports": {
".": {
"types": "./index.d.cts",
"import": "./index.js",
"require": "./index.cjs"
}
}
}
可以对照着读:
- 编辑期: 倾向走
types→index.d.cts - ESM 运行时: 走
import→index.js - CJS 运行时: 走
require→index.cjs
本节重点: 同一条
exports["."],可以让类型和运行时落到不同文件。 这是编辑期与运行时最直观的分叉点。
对照:nanoid(体量小,结构清楚)
nanoid 常见写法是根上声明 types,并在 exports 里同时给出类型与默认实现(节选示意):
json
{
"types": "./index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"browser": "./index.browser.js",
"default": "./index.js"
}
}
}
对 import { nanoid } from 'nanoid' 做跳转时,语言服务通常先落到 types 指向的声明文件;打包器或 Node 再按环境去选 default / browser 等运行时条件。
对照:自己不带类型,靠 @types/*
有些包发布物里几乎没有声明文件,生态会走 DefinitelyTyped:另装 @types/包名(例如历史上常见的 @types/lodash)。此时运行时仍来自原来的包,类型表面来自 @types/*。两套版本号可能独立演进------提示正常,不代表与实现锁步发布。
tsconfig 在这条链上管什么
tsconfig.json 不负责改写 npm 压缩包里有什么。它改的是两件事:
- 按哪套规则 去
node_modules里解析模块和类型; - 自动带上哪些 全局
@types(不必import也能用的那些)。
下面只展开和「依赖类型对不上」最相关的几项;完整选项表见 TSConfig Reference。
moduleResolution:用哪套查找算法
决定遇到 import 'zod' 时,认不认 包上的 exports、以及里面的 types 条件。
| 常见取值 | 直观效果 |
|---|---|
bundler / node16 / nodenext |
按现代规则读 exports,类型条件与运行时条件可以分叉 |
偏旧的 node(旧文档里的 Node 解析) |
更依赖顶层 types、main,以及「.js 旁边有没有 .d.ts」;对复杂 exports 可能解析不完整 |
所以会出现:同一个依赖,A 仓库悬浮正常、B 仓库却跳进无类型的 .js------先对比两边的 moduleResolution,再怀疑包本身。
它不 决定装哪个版本,也不 生成 .d.ts。
paths:别名,且优先于 node_modules
把导入字符串映射到项目里的路径,例如 "@/*": ["./src/*"]。解析时往往先看 paths ,命中就不再去 node_modules 找同名包。
排错含义:跳转进了仓库内部一份手写声明或旧拷贝,而不是 npm 上的包------优先查 paths(以及是否配了 baseUrl),不要先怪依赖作者。
它只影响本项目的解析,改不了别人安装你的包时怎么解析。
typeRoots:自动 @types 从哪几个目录扫
默认会从 node_modules/@types 下扫描可自动启用的全局类型包。改成别的目录后,就只从你指定的根去扫。
常见误用:自定义了 typeRoots 却忘了把 node_modules/@types 加回去,结果 @types/node 明明装着,全局的 process 等却突然消失。
它管的是自动纳入的搜索根,不是「禁止 import 某个包的类型」。
types(tsconfig 里):白名单------哪些全局 @types 自动启用
这里的 types 和 package.json 里的 types 同名不同义:
| 写在哪 | 含义 |
|---|---|
package.json 的 types |
这个 npm 包的类型入口文件在哪 |
tsconfig 的 types |
编译启动时,自动启用 typeRoots 里的哪些包名 (如 "types": ["node"] 只自动启用 @types/node) |
要点:
- 不写
types字段:默认尽量把typeRoots下的包都自动纳入(具体以当前 TypeScript 行为为准)。 - 写了
"types": ["node"]:通常表示「全局只自动启用列出的这些」;没写上的@types/jest等,不会再默默进全局。 - 不影响
import 'lodash'再去解析@types/lodash这类「跟着模块导入走」的类型------那是模块解析,不是全局自动纳入。
可以把 tsconfig 的 types 想成:全局环境白名单 ;把 import 想成:按需去解析那个模块的类型。
skipLibCheck:跳过声明文件深查,但不改「读哪份」
设为 true 时,跳过对 node_modules 里大量 .d.ts 的逐条严格检查,加快 tsc,也减少「依赖之间声明互相冲突」带来的报错噪音。
它不改变 解析到哪一份声明文件,也不关闭 对你自己业务代码的检查。提示仍指向同一份 .d.ts,只是编译器不再深入检查这些声明文件内部的类型错误。
本节重点: 包上的
types/exports.types说明声明文件在哪;tsconfig 说明按什么规则去找、自动启用哪些全局@types。moduleResolution管算法,paths管别名,typeRoots+types管全局@types,skipLibCheck只管检查松紧。声明如何被生成,可另文再写。
和运行时入口怎么对齐排查
同一张 package.json 上,既有给 Node / 打包器用的运行时入口,也有给语言服务用的类型入口,读的人不同:
| 问题 | 优先看 | 典型症状 |
|---|---|---|
| 运行时报找不到模块 / 加载错文件 | exports 的 import/require/default,以及 files |
构建或 Node 报错 |
能跑,但没有提示、跳进了 .js 且毫无类型 |
包是否自带 types / exports.types;旁路 .d.ts 是否打进包;缺则是否安装 @types/*;moduleResolution 是否认 exports |
编辑器「不智能」 |
| 提示有,运行行为却对不上 | 声明是否过时、是否改错了本地 .d.ts、是否装错了主版本 |
「类型说可以,运行却炸」 |
| 只有某个项目提示不对 | 该项目的 moduleResolution / paths / typeRoots,以及是否叠了第二个语言服务 |
换仓库或换工作区才复现 |
排查口诀: 跑不起来 → 先查运行时入口与安装清单;提示不对 → 先查包自带类型,再查
@types,最后才怀疑 tsconfig 解析模式。
三种容易混的错觉
-
有类型 ≠ 有完整运行时实现
声明文件可以描述一个函数;真正执行仍取决于实现文件是否存在、是否被装进包。
-
改本地
node_modules里的.d.ts≠ 改了依赖行为最多影响当前编辑器与类型检查;一刷新安装或换机器就回去了,也不会让线上用户自动获得同样的修改。
-
源码仓库里的注释 / JSDoc,未必出现在已安装包里
若发布清单没带上对应声明或被裁掉,编辑器只能基于装到的文件提示。对照时仍以
node_modules里的内容为准,而不是只看 GitHub 默认分支。
公开包上也能看到「类型只出现在 exports 条件里、顶层 types 很薄或另有安排」的形态。迭代快的 SDK 尤其常见:工具链若只认旧字段,观感会和现代语言服务不一致。排查时打开对方的 package.json,看类型条件实际指向哪里,比凭包名猜测更稳。
类型契约要点
- 链路: 编辑器经 LSP(语言服务器协议)问到语言服务,再落到
.d.ts/ 可读的源码类型;VS Code 等环境里,这份服务常常是内置的 tsserver。 - 查找顺序: 包自带(
exports.types/ 顶层types/ 与.js旁路的.d.ts)→ 再@types/*;标准在 TypeScript 模块解析里。 - tsconfig:
moduleResolution等决定怎么找,不改写 npm 包内容;tsconfig 的types与 package.json 的types同名异义。 - 边界: 类型入口解释「提示与检查」;
import/require/default等解释「执行哪份文件」。框架扩展若与内置服务冲突,先理顺「谁在回答」。
版本号与发行标签如何决定装到哪一版依赖,以及安装后为何能在脚本里直接跑命令行工具,不在本文范围。
参考与数据源
- TypeScript:Declaration Files(声明文件)
- TypeScript:Publishing declaration files(自带类型 vs
@types) - TypeScript:Modules 手册导读
- npm CLI:
package.json字段说明(含types/exports) - Node.js:Packages --- package entry points
- DefinitelyTyped:仓库说明
- 文中
zod、nanoid的字段摘自撰稿时的 npm 最新稳定版;阅读时建议用npm view <包名> types、npm view <包名> exports对照当前结果