TypeScript in Node:类型、构建与运行时校验
Node 项目写 TS 和后端同学想象的"编译型语言"不太一样:TS 的类型在运行时全部消失,它只在编译期帮你检查。这就带来一个关键结论:类型安全 ≠ 运行时安全。数据库返回的 JSON、HTTP 请求体、环境变量——这些"外面的数据"在类型系统眼里都是不可信的,TS 那句 as User 只是你的一句口头保证。这一章讲清三件事:tsconfig 怎么配、代码怎么跑、边界处怎么校验。
论把 TS 想象成"编译期的 lint + 编辑器提示"
它做的是:在写代码时就告诉你"这个字段不存在""这个可能为 undefined""参数类型不对",把一整类 bug 提前到敲键盘的时候发现。项目越大、人越多、活得越久,收益越明显。
它不做的是:不做运行时校验。用户往 /api/users POST 一个 {"age": "abc"},TS 编译器不在线上值班,它拦不住。所以真正靠谱的组合是"TS 管编译期 + zod 管运行时",两把锁一起上。
它还有代价:多一层构建(或者多一个 loader)、类型报错有时很难修(尤其是第三方库类型)、any 滥用会让收益归零。这就是为什么"边界处必须校验、内部代码保持强类型"是性价比最高的策略。
tsconfig:这几个开关决定你的体验
网上的 tsconfig 抄来抄去,但真正影响"写 Node 舒不舒服"的就是下面这些。建议直接从这份改,不要从零写。
| 选项 | 为什么重要 |
|---|---|
module + moduleResolution = NodeNext | Node 的 ESM 解析规则有"必须带扩展名、exports 字段优先"等特殊要求。这两项写成 ESNext + bundler,你就会遇到"编辑器不报错、Node 一跑就 ERR_MODULE_NOT_FOUND"。写 Node 服务就用 NodeNext。 |
noUncheckedIndexedAccess | 打开后 obj[key]、arr[0] 的类型会带上 | undefined,逼你处理"取不到"的情况。刚开始会觉得烦,但它挡掉的正是线上最常见的 Cannot read properties of undefined。 |
verbatimModuleSyntax | 要求"只导入类型"时必须写 import type { User } from "./types"。好处是产出更干净、循环依赖更少,也让类型擦除型运行器(tsx、Node 原生)能正确判断哪些导入可以删。 |
代码怎么跑:四种方式,各有各的位置
| 方式 | 命令 | 优点 | 注意 |
|---|---|---|---|
| tsx (开发推荐) |
tsx watch src/index.ts |
基于 esbuild,启动毫秒级、自带 watch 热重载、支持 tsconfig paths。 | 只转译不检查类型,必须另外跑 tsc --noEmit。 |
| ts-node (老方案) |
ts-node src/index.ts |
历史最久,生态文档多。 | ESM 下配置麻烦(要 loader / experimental 开关),启动慢。新项目不必再用。 |
| tsc 先编译再跑 (生产推荐) |
tsc -b && node dist/index.js |
产出真实 JS,启动快、无运行时依赖、类型检查一步到位。 | 多一步构建;改代码要重新编译(开发时用 tsc -w 或干脆开发用 tsx)。 |
| Node 原生类型擦除 (新趋势) |
node --experimental-strip-types src/index.ts |
零依赖直接跑 TS,Node 22.6 起可实验启用、后续版本逐步默认开启。 | 只能擦除类型,不支持需要"生成代码"的语法(enum、namespace、参数属性、装饰器元数据等)。要用就配 erasableSyntaxOnly 让 TS 提前拦你。 |
日常脚本组合:开发用 tsx,检查用 tsc,生产用 tsc 产物
坑一:以为 tsx/vitest 跑通就是类型没问题。它们都只做类型擦除,不做类型检查。CI 里必须有一步 tsc --noEmit,否则"类型错误"会一路混到线上。
坑二:ESM 里 import 忘了写扩展名。Node 的 ESM 不做扩展名推断,import { a } from "./utils" 会直接报 ERR_MODULE_NOT_FOUND。要么写 ./utils.js(TS 里也写 .js,开启 NodeNext 后 TS 会正确映射回 .ts),要么用打包器(tsup/esbuild)产出。
坑三:用 as 硬转外部数据。const user = req.body as User 只是让编译器闭嘴,运行时该崩还是崩。所有外部输入(body / query / 环境变量 / 第三方响应)都必须过一遍运行时校验。
坑四:import { User } from "./types" 却 User 是个类型。在 ESM 下这会保留一个真实的 import 语句,运行时报"没有这个导出"。开 verbatimModuleSyntax,类型一律用 import type。
坑五:用 enum 又想用 Node 原生擦除跑。enum 会生成运行时代码,原生擦除不支持。改用字面量联合类型或 as const 对象——它们更轻、更符合现代 TS 的主流实践。
运行时校验:zod 在边界处把类型"钉死"
编译器管不了外部数据,就得靠运行时校验。zod 的价值不只是"校验",而是校验成功后它能自动推导出 TS 类型——一份 schema 同时充当"运行时规则"和"编译期类型",不用同步维护两处。
Express 里怎么用:一个校验中间件,后面全都拿到强类型
一个值得养成的习惯:把"校验"和"业务逻辑"分开。校验只发生在系统边界(HTTP 入口、数据库出口、外部 API 响应、环境变量),一旦数据进入你的内部函数,就应该是干净、强类型的——内部代码不要再到处写防御性判断,那是重复劳动,也让真正的错误被掩盖。
给第三方类型打补丁:声明合并
真实项目里总会遇到"我想在 req 上挂一个 user"、"这个库类型不准"这类情况。TS 提供了声明合并(declaration merging)来扩展已有类型。
打库/发包:exports 字段与 ESM 的坑
如果你要发布的不是应用而是一个 npm 包,类型和入口的配置就非常重要——配错了,使用者会遇到"找不到类型声明"或"ESM 里 import 报错"。
exports字段一旦写了,就会"锁死"外部只能访问你列出来的路径。老项目里常见的import "包/lib/xxx"深路径会全部失效——发布前先确认下游有没有这么用的。- 类型声明可以用
isolatedDeclarations加速(TS 5.5+ 的实验特性):让每个文件能独立生成 .d.ts,配合并行构建能把打库时间显著压下来。 - sourcemap 一定要发(并带上
sourcesContent)。线上报错的堆栈如果映射不回 TS 源码,排查成本会翻好几倍。
① TS 的类型在运行时全部消失,所以"类型安全 ≠ 运行时安全";边界必须用 zod 之类的工具做运行时校验。
② tsconfig 关键三项:module/moduleResolution 用 NodeNext、strict + noUncheckedIndexedAccess 打开、verbatimModuleSyntax 让类型导入显式化。
③ 跑 TS 四选一:开发用 tsx、生产用 tsc 产物、ts-node 属老方案、Node 原生类型擦除只支持"可擦除语法"(不能用 enum/namespace)。
④ 一份 zod schema = 运行时规则 + 编译期类型(z.infer),连环境变量也该在启动时校验并快速失败。
⑤ tsx 跑通不代表类型没问题,CI 里必须有独立的 tsc --noEmit。
1.(概念题)为什么说"TS 通过了编译,运行时照样可能崩"?举两个 Node 里的具体例子。
查看答案
答案:因为类型只存在于编译期,运行时全部擦除。两个典型例子:① const dto = req.body as CreateUser——as 只是让编译器闭嘴,实际 body 里 age 可能是字符串、字段可能缺失,运行到 dto.age.toFixed() 就崩;② 数据库查询结果、第三方 API 响应、JSON 配置文件、环境变量,这些"外部数据"TS 完全无法验证,你写的 interface 只是单方面假设。修法是所有边界做运行时校验(zod)。
2.(排错题)项目里 tsx src/index.ts 跑得好好的,CI 里加了一步 tsc --noEmit 后报了 80 个类型错误。这说明什么?该怎么办?
查看答案
答案:说明 tsx 只做类型擦除、不做类型检查,类型错误一直存在,只是没人发现。这不是"tsc 太严",而是项目本来就带着 80 个潜在 bug。正确处理:① 先把 tsc --noEmit 保留为 CI 的必过项;② 用 // @ts-expect-error 或临时放宽个别规则灰度修复,不要用 // @ts-nocheck 把整个文件关掉;③ 排查错误里有多少是 undefined 相关(那就是真实 bug 而不是类型噪音),优先修这些。
3.(对比题)Node 原生"类型擦除"和 tsx 有什么区别?为什么它不能跑 enum?
查看答案
答案:两者都只做类型剥离、都不做类型检查,区别在"谁来做"和"能处理什么语法"。Node 原生擦除是内置能力、零依赖(Node 22.6 起实验性支持,后续逐步默认);tsx 是外部工具,可以做路径别名、更灵活的转译。关于 enum:它在 TS 里不只是一个类型,还会生成运行时代码(一个真实的对象),而"擦除"的定义是把类型语法直接删掉、不生成任何代码,所以遇到 enum 就没法处理。同理 namespace、参数属性、装饰器元数据也不行。建议改用字面量联合类型或 as const。
4.(实践题)你写了一个通用校验中间件把 req.body 校验后放到 req.validated。为什么建议用解析后的数据覆盖原始数据,而不是只做校验?
查看答案
答案:因为"校验通过"和"数据干净"是两件事。校验器在完成校验时通常还会做三件有价值的事:① 剔除多余字段(如 strict 模式,防止攻击者塞入 __proto__ 或额外属性);② 应用默认值与类型转换(z.coerce.number() 把 "18" 变成 18、default 补上缺失值);③ 归一化(trim 空白、统一小写邮箱)。只校验不覆盖,业务代码拿到的还是那份原始脏数据,等于前面的工作白做。
5.(配置题)同事在 Node ESM 项目里写了 import { helper } from "./utils",编辑器不报错,node dist/index.js 却报 ERR_MODULE_NOT_FOUND。请解释原因并给出三种解法。
查看答案
答案:原因是 Node 的 ESM 解析不做扩展名推断,相对导入必须写出完整文件名;而 TS 默认允许省略(编辑器不报错),两边规则不一致就出现了"编辑器不报错、一跑就崩"。
三种解法:① 补扩展名:写 import { helper } from "./utils.js"(TS 源码里也写 .js,开启 moduleResolution: NodeNext 后 TS 会自动映射回 utils.ts),这是最标准、零依赖的做法;② 生产改用打包器:用 tsup/esbuild 把入口打成单文件产物,裸相对导入会在打包阶段被内联,运行时就只剩一个文件;③ 用 imports 子路径别名:在 package.json 里声明 "imports": { "#utils": "./dist/utils.js" },代码里统一 import { helper } from "#utils",由 Node 按映射解析。另外注意:tsx 这类运行器能容忍省略扩展名,所以本地跑得通不代表打包后跑得通,发布前一定要用真实产物验证一次。