楼层: 首页/ 软件技术/ Node.js 全栈实战/ TypeScript in Node:类型、构建与运行时校验
15

TypeScript in Node:类型、构建与运行时校验

TypeScript in Node: tsconfig, Runners, Build & Runtime Validation

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 舒不舒服"的就是下面这些。建议直接从这份改,不要从零写。

// tsconfig.json —— Node 22+ / ESM 项目的现代配置 { "compilerOptions": { /* === 目标与模块:Node 项目最重要的两项 === */ "target": "ES2023", "module": "NodeNext", // 让 TS 按 Node 的规则解析 import/export "moduleResolution": "NodeNext", // 必须和 module 配套,否则类型解析会错 "lib": ["ES2023"], /* === 严格度:新项目全部打开,别犹豫 === */ "strict": true, "noUncheckedIndexedAccess": true, // arr[0] 变成 `T | undefined`,专治下标越界 "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, /* === 模块互操作与风格 === */ "verbatimModuleSyntax": true, // 类型导入必须写 import type,产出更干净 "esModuleInterop": true, "skipLibCheck": true, // 跳过 node_modules 的类型检查,构建快很多 "isolatedModules": true, // 让每个文件能独立转译(esbuild/tsx 的前提) /* === 产出 === */ "outDir": "dist", "rootDir": "src", "sourceMap": true, // 线上报错能映射回 TS 源码,必开 "declaration": true, // 打库才需要,应用不需要 "resolveJsonModule": true, "types": ["node"] // 只加载 @types/node,避免全局类型污染 }, "include": ["src/**/*"] }
三个最容易配错、也最容易被忽视的选项
选项为什么重要
module + moduleResolution = NodeNextNode 的 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 产物

// package.json scripts { "type": "module", "scripts": { "dev": "tsx watch src/index.ts", // 开发:秒起 + 热重载 "typecheck": "tsc --noEmit", // 独立做类型检查(CI 必跑) "build": "tsc -b", // 生产构建:产出 dist/*.js + sourcemap "start": "node dist/index.js", "test": "vitest run" }, "devDependencies": { "tsx": "^4", "typescript": "^5", "@types/node": "^22" } }
TS in Node 的五个高频坑

坑一:以为 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 同时充当"运行时规则"和"编译期类型",不用同步维护两处。

// schemas/user.ts —— 一份 schema,两处受益 import { z } from "zod"; export const CreateUser = z.object({ // zod 4 起推荐用 z.email();老版本写 z.string().email() email: z.email(), name: z.string().min(1).max(50), age: z.number().int().min(0).max(150).optional(), role: z.enum(["user", "admin"]).default("user"), }).strict(); // strict:多余字段直接报错(防被塞 __proto__ 之类) // 反过来推导出类型:类型和 schema 永远同步,不会写歪 export type CreateUser = z.infer<typeof CreateUser>; // 环境变量也可以校验:缺了、格式错了,启动就失败(比运行时才发现好一万倍) export const Env = z.object({ NODE_ENV: z.enum(["development", "test", "production"]), PORT: z.coerce.number().int().positive(), // 环境变量都是字符串,coerce 自动转 DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), // 太短的密钥直接不让启动 });

Express 里怎么用:一个校验中间件,后面全都拿到强类型

import { z, ZodError } from "zod"; // 通用校验中间件:把校验结果写回 req,类型安全地传给路由 const validate = (schema) => (req, res, next) => { const parsed = schema.safeParse({ body: req.body, query: req.query, params: req.params }); if (!parsed.success) { return res.status(400).json({ error: "参数不合法", details: parsed.error.issues.map(i => ({ path: i.path.join("."), message: i.message })), }); } // 用解析后的"干净数据"覆盖原始 req(strict 模式下多余字段已被剔除) req.validated = parsed.data; next(); }; app.post("/users", validate(z.object({ body: CreateUser })), (req, res) => { const data = req.validated.body; // 类型就是 CreateUser // 这里开始,data 是可信的强类型数据,可以放心往下传 });

一个值得养成的习惯:把"校验"和"业务逻辑"分开。校验只发生在系统边界(HTTP 入口、数据库出口、外部 API 响应、环境变量),一旦数据进入你的内部函数,就应该是干净、强类型的——内部代码不要再到处写防御性判断,那是重复劳动,也让真正的错误被掩盖。

给第三方类型打补丁:声明合并

真实项目里总会遇到"我想在 req 上挂一个 user"、"这个库类型不准"这类情况。TS 提供了声明合并(declaration merging)来扩展已有类型。

// src/types/express.d.ts —— 扩展 Express 的 Request import "express"; declare global { namespace Express { interface Request { // 认证中间件挂上去的用户信息,全项目可见 user?: { id: string; role: "user" | "admin" }; // 校验中间件写入的数据 validated?: { body?: unknown; query?: unknown }; } } } // 记得导出一次,否则这个文件不算"模块",declare global 可能不生效 export {};
// 更严格的写法:用泛型把"这一条路由"的类型也带上 // 定义一个带类型的请求类型,路由处理函数就有了完整的类型提示 type ValidatedRequest<T> = import("express").Request & { validated: { body: T } }; app.post("/users", validate(...), (req: ValidatedRequest<CreateUser>, res) => { const { email, name } = req.validated.body; // 有补全、有检查 });
// 环境变量:用同一个 zod schema 校验,然后导出强类型配置对象 import { z } from "zod"; const parsed = Env.safeParse(process.env); if (!parsed.success) { // 启动即失败:比线上跑到一半发现少了个变量好得多 console.error("环境变量配置有误:", parsed.error.flatten().fieldErrors); process.exit(1); } export const config = parsed.data;

打库/发包:exports 字段与 ESM 的坑

如果你要发布的不是应用而是一个 npm 包,类型和入口的配置就非常重要——配错了,使用者会遇到"找不到类型声明"或"ESM 里 import 报错"。

// package.json —— 同时支持 ESM 与 CJS 的现代写法 { "name": "@acme/logger", "version": "1.2.0", "type": "module", "files": ["dist"], "main": "./dist/index.cjs", // 老工具 / CJS 使用者的兜底入口 "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", // types 条件必须放最前面 "import": "./dist/index.js", "require": "./dist/index.cjs" } }, "sideEffects": false, "engines": { "node": ">=20.19" } } // 构建用 tsup(内部是 esbuild):一条命令同时产出 ESM + CJS + d.ts // npx tsup src/index.ts --format esm,cjs --dts --sourcemap
  • 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。

本章自测(TypeScript in Node)

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 这类运行器能容忍省略扩展名,所以本地跑得通不代表打包后跑得通,发布前一定要用真实产物验证一次。