Chapter 10

实战:Express API 完全类型化

将 Express.js 应用完全类型化,集成 Zod 运行时验证,构建类型安全的 REST API

为什么类型安全的 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 类型系统后,你会发现很多"运行时错误"其实是"类型错误"——而类型错误在编写代码时就能被发现。

本章小结

本章核心要点