为什么类型安全的 API 很重要?
一个未经类型化的 Express API 存在以下典型问题:req.body 的类型是 any,意味着你对传入的数据一无所知;route params 是 string 但开发者可能忘记转换;响应格式不一致导致前端处理困难;错误处理代码散落各处。
本章展示如何用 TypeScript + Zod 解决这些问题:Zod 在运行时验证并解析数据,同时自动推导出 TypeScript 类型,做到"运行时验证"和"编译时类型安全"两者合一。
Express + TypeScript 项目搭建
mkdir express-api && cd express-api
npm init -y
npm install express
npm install --save-dev typescript @types/express @types/node tsx
npx tsc --init # 生成 tsconfig.json
类型化的 Request 和 Response
// src/types/index.ts — 应用级类型定义
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: { code: string; message: string };
meta?: { page?: number; total?: number };
}
Zod:运行时验证 + 类型推导
Zod 是一个 TypeScript 优先的 Schema 验证库,核心优势是:定义一次 schema,同时获得运行时验证能力和 TypeScript 类型。z.infer<typeof Schema> 从 schema 自动推导出静态类型,彻底消除"Schema 和类型声明不同步"的问题。
schema.parse(data)
验证并返回符合 schema 的数据(经过 transform/default 处理后)。如果验证失败,抛出 ZodError(包含所有错误的详细路径和消息)。
schema.safeParse(data)
不抛出异常,返回 { success: true, data } 或 { success: false, error: ZodError }。在中间件和 API 处理中优先使用,避免 try-catch 嵌套。
z.infer<typeof Schema>
从 Zod schema 提取 TypeScript 类型,这是单一数据源(Single Source of Truth)的体现——schema 和类型保持同步,修改 schema 自动更新类型。
npm install zod
// src/schemas/user.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
});
// 从 Zod schema 自动推导 TypeScript 类型
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
// { name: string; email: string; age?: number | undefined; role: 'admin'|'user'|'guest' }
类型安全的 Router
// src/middleware/validate.ts
import { RequestHandler } from 'express';
import { ZodSchema } from 'zod';
export function validate(schema: ZodSchema): RequestHandler {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: result.error.issues.map(i => i.message).join(', '),
},
});
}
req.body = result.data; // 替换为验证后的数据
next();
};
}
// src/routes/users.ts
import { Router } from 'express';
import { CreateUserSchema, CreateUserInput } from '../schemas/user';
import { validate } from '../middleware/validate';
import type { ApiResponse } from '../types';
const router = Router();
router.post('/', validate(CreateUserSchema), (req, res) => {
const input = req.body as CreateUserInput;
// input 已经过 Zod 验证,类型完全安全
const user = { id: Date.now(), ...input };
const response: ApiResponse<typeof user> = {
success: true,
data: user,
};
res.status(201).json(response);
});
export default router;
类型化的错误处理中间件
// src/middleware/errorHandler.ts
import { ErrorRequestHandler } from 'express';
import type { ApiResponse } from '../types';
// 注意:错误处理中间件必须有 4 个参数
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
console.error(err.stack);
const response: ApiResponse = {
success: false,
error: {
code: err.code ?? 'INTERNAL_ERROR',
message: err.message ?? 'Internal Server Error',
},
};
res.status(err.statusCode ?? 500).json(response);
};
环境变量类型化
// src/config/env.ts — 类型安全的环境变量
import { z } from 'zod';
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: z.string().min(32),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ 环境变量配置错误:', parsed.error.format());
process.exit(1);
}
export const env = parsed.data;
export type Env = z.infer<typeof EnvSchema>;
完整的 Express 应用入口
// src/app.ts — 完整的应用入口
import express from 'express';
import { env } from './config/env';
import usersRouter from './routes/users';
import { errorHandler } from './middleware/errorHandler';
const app = express();
// 中间件
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 路由
app.use('/api/users', usersRouter);
// 健康检查端点
app.get('/health', (req, res) => {
res.json({ status: 'ok', env: env.NODE_ENV });
});
// 404 处理(必须在所有路由之后)
app.use((req, res) => {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '路由不存在' } });
});
// 错误处理中间件(必须有 4 个参数,且在所有路由之后注册)
app.use(errorHandler);
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT} [${env.NODE_ENV}]`);
});
TypeScript 与 Express 类型集成的边界情况
req.params 的类型陷阱
Express 的
req.params 在 TypeScript 中类型是 ParamsDictionary(即 Record<string, string>)——所有路径参数都是字符串,包括 :id。使用 Request<{ id: string }> 声明参数类型,然后手动转换 parseInt(req.params.id) 或用 Zod coerce。永远不要直接把 req.params.id 当作数字使用。req.user 的类型扩展
JWT 认证中间件会向 req 添加 user 属性,但 Express 的 Request 类型默认没有它。正确做法是声明合并(augmentation):在 d.ts 文件中通过
declare namespace Express { interface Request { user?: User } } 扩展 Request 类型,而不是用 as 断言 (req as any).user。async 路由处理器的错误传递
Express 不自动捕获 async 函数中抛出的错误——错误不会传递给错误处理中间件,而是导致 Promise rejection 被静默忽略(Node.js 18 前)。解决方案:(1) 用 express-async-errors 库自动包装;(2) 手动 try-catch 后调用
next(err);(3) 升级到 Express 5(原生支持 async 路由)。Express 4 的 async 错误是静默的
以下代码在 Express 4 中错误会被静默吞掉,不会传到 errorHandler:
// ❌ Express 4 中 async 错误不会自动到 errorHandler
router.get('/:id', async (req, res) => {
const user = await User.findById(req.params.id); // 如果抛异常...
res.json(user); // ...这行不会执行,错误也不会被处理!
});
// ✅ 正确做法:手动 try-catch + next(err)
router.get('/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
res.json(user);
} catch (err) {
next(err); // 传递给 errorHandler
}
});
Zod 高级用法
// 更丰富的 Zod schema 特性
import { z } from 'zod';
// 1. transform:验证同时转换数据类型
const IdSchema = z.string().transform(Number).pipe(z.number().int().positive());
// 输入 "42" → 输出 42(number 类型)
// 2. refine:自定义验证规则
const PasswordSchema = z.string()
.min(8, '至少 8 个字符')
.refine(v => /[A-Z]/.test(v), '必须包含大写字母')
.refine(v => /[0-9]/.test(v), '必须包含数字');
// 3. superRefine:跨字段验证
const SignupSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine(({ password, confirmPassword }, ctx) => {
if (password !== confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmPassword'],
message: '两次密码不一致',
});
}
});
// 4. discriminatedUnion:高效的可辨识联合验证
const NotificationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), address: z.string().email() }),
z.object({ type: z.literal('sms'), phone: z.string() }),
z.object({ type: z.literal('push'), deviceToken: z.string() }),
]);
type Notification = z.infer<typeof NotificationSchema>;
// { type: 'email'; address: string } | { type: 'sms'; phone: string } | ...
课程总结:TypeScript 的价值
TypeScript 不是"带类型的 JavaScript"那么简单——它是一个完整的类型系统,让你能够:用类型表达业务规则(可辨识联合表示状态机)、在编译时捕获逻辑错误(穷举检查)、让代码自文档化(函数签名即文档)、安全地重构(类型错误引导你找到所有需要修改的地方)。掌握 TypeScript 类型系统后,你会发现很多"运行时错误"其实是"类型错误"——而类型错误在编写代码时就能被发现。
本章小结
本章核心要点
- 类型化 Request/Response:Express 的泛型参数 Request<Params, ResBody, ReqBody, Query> 可以精确声明路由的输入输出类型;但这只是静态类型,运行时仍需 Zod 等库验证实际数据。
- Zod 的单一数据源:用 z.infer 从 schema 提取类型,schema 即类型定义——修改一处两处同步更新,彻底告别手动维护类型和验证逻辑的重复工作。
- validate 中间件:在路由处理前通过 Zod safeParse 验证并转换 req.body,失败时立即返回 400 错误;成功时 req.body 已是经过验证和 transform 的干净数据。
- 环境变量类型化:在应用启动时用 Zod 验证 process.env,配置缺失时立即退出并报错(快速失败原则),避免在运行时深处才发现配置问题。
- 统一错误处理:ErrorRequestHandler 中间件集中处理所有未捕获错误,统一响应格式;配合自定义错误类(携带 statusCode)可以精确控制 HTTP 状态码。