写Node.js接口时,前端传来的body到底长什么样,很多时候全凭记忆。后端拿到req.body后一股脑解构,字段拼错了只能靠运行时报错发现;返回给前端的数据结构也没有统一约定,联调时反复扯皮。这些问题的根源在于缺少一套贯穿请求和响应的类型体系。TypeScript本身的表达能力足够强,缺的只是一层有意识的封装。下面从请求体、响应体、运行时校验和工程化组织四个层面,把这件事讲透。

为什么要封装请求与响应体类型
先看一段常见的Express代码。req.body在默认类型声明里是any或者宽泛的对象类型,你在控制器里写的每一个字段访问,TypeScript都帮不上忙:
app.post('/users', (req, res) => {
// name 字段是否存在?类型是什么?编译器完全不知道
const { name, email, age } = req.body;
if (!name) {
return res.status(400).json({ message: 'name is required' });
}
createUser(name, email, age); // email 可能是 undefined,age 可能是字符串
});这段代码的问题不只是“不够优雅”。当字段数量增多、嵌套层级变深时,email、age这类未校验的字段会悄悄流向业务层,直到某个数据库报错或者页面渲染出undefined才暴露问题。而且错误响应的格式全靠每个路由自己发挥,前端无法写统一的错误处理逻辑。
封装的目标有两个:一是让每个路由的请求参数、查询串、路由参数都有明确的静态类型;二是让所有接口的响应遵循统一信封结构,成功与失败、数据与错误信息各归其位。这两个目标分别靠泛型请求类型扩展和响应体工具类型来实现。
请求体类型的封装:扩展Request定义
Express的Request类型本身就是泛型接口,签名大致是Request<Params, ResBody, ReqBody, ReqQuery>。利用这一点,可以为每个路由精确声明参数类型,而不需要全局类型断言。
直接内联泛型的写法可行但繁琐,更好的做法是定义工具类型,把params、body、query打包声明:
import type { Request } from 'express';
// 泛型工具类型:一次性声明路由参数、请求体、查询串
type TypedRequest<
P = Record<string, never>,
B = Record<string, never>,
Q = Record<string, never>
> = Request<P, any, B, Q>;
// 针对具体接口定义请求体类型
interface CreateUserBody {
name: string;
email: string;
age?: number;
role?: 'admin' | 'user';
}
type CreateUserRequest = TypedRequest<
{}, // 路由参数
CreateUserBody, // 请求体
{ source?: string } // 查询串
>;在控制器中使用时,直接把类型标注到函数参数上,req.body就获得了完整的类型提示:
const createUser = (req: CreateUserRequest, res: Response) => {
const { name, email, age, role } = req.body;
// name 被推导为 string,age 是 number | undefined
// role 只能取 'admin' 或 'user',写错值编译期就报错
const user = userService.create({ name, email, age, role });
res.status(201).json({ code: 0, data: user });
};这种方式的优点是零侵入,不改变任何运行时行为,纯粹是编译期的类型约束。缺点也明显:TypeScript的类型只在编译期有效,运行时用户实际提交什么数据,编译器管不了。这就引出了后面的运行时校验话题。
响应体的统一封装与类型推导
响应体封装的核心是定义一个统一的响应信封。一个典型的结构包含业务状态码、提示信息和数据区,配合分页场景再包一层通用容器:
// 统一响应结构
interface ApiResponse<T = unknown> {
code: number; // 0 表示成功,非 0 表示业务错误
message: string;
data: T;
}
// 分页数据的通用包装
interface PageData<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}光有接口定义还不够,配合一个泛型工厂函数,可以让每个接口的返回类型自动推导,避免手动重复标注:
function success<T>(data: T, message = 'ok'): ApiResponse<T> {
return { code: 0, message, data };
}
function fail(code: number, message: string): ApiResponse<null> {
return { code, message, data: null };
}
// 使用示例:返回类型自动推导为 ApiResponse<PageData<User>>
const listUsers = (req: Request, res: Response) => {
const page = userService.findPage(req.query);
res.json(success(page));
};这里有个细节值得注意:success函数的泛型参数T不需要显式传入,TypeScript会根据实参自动推导。也就是说,只要业务层函数的返回类型标注准确,整条链路上的类型就是连贯的。前端如果也在用TypeScript,可以把ApiResponse类型定义抽到共享包中,接口文档和对齐成本大幅降低。
用zod打通静态类型与运行时校验
前面提到,类型标注管不住运行时的脏数据。传统做法是手写校验函数或用joi、class-validator,但这些方案的校验规则和TypeScript类型是两套体系,很容易改了类型忘了改校验。zod的思路是把校验规则作为唯一数据源,类型从规则中推导出来。
import { z } from 'zod';
// 定义校验规则
const createUserSchema = z.object({
name: z.string().min(1).max(50),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user']).default('user'),
});
// 从规则推导类型,替代手写 interface
type CreateUserBody = z.infer<typeof createUserSchema>;
// CreateUserBody 的结构与上面的 schema 完全一致上面代码中z.infer是关键,它让校验规则和类型永远保持同步:改了schema,类型立刻跟着变;schema允许的字段,类型里一定存在。再配合中间件做统一校验,控制器拿到的req.body已经被解析、清洗过,可选字段带默认值,枚举取值合法,控制器里可以完全信任数据。
import { NextFunction } from 'express';
const validate = (schema: z.ZodTypeAny) =>
(req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
// 将校验错误整理成统一的失败响应
return res.status(400).json(
fail(40001, result.error.issues.map(i => i.message).join('; '))
);
}
req.body = result.data; // 用解析后的干净数据替换原 body
next();
};
app.post('/users', validate(createUserSchema), createUser);工程化组织与常见踩坑点
类型文件多了之后怎么放?推荐的分层方式是:与传输协议相关的请求响应类型放在接口层(比如types/api目录),与业务实体相关的类型放在领域层,两者不要混用。业务函数接收和返回领域类型,控制器负责在请求类型与领域类型之间转换,这样即使未来从Express迁移到Fastify,领域层的类型一行都不用改。
几个高频踩坑点需要留意。第一,Express 4的@types/express版本较旧时泛型参数只有三个,升级类型包版本前先确认签名。第二,strictNullChecks务必打开,否则可选字段的undefined风险形同虚设。第三,zod的.default()会让推导出的输入类型与输出类型不同,如果需要区分,用z.input和z.output两个工具类型分别获取。第四,统一响应信封一旦上线就不要轻易改动结构,新增字段可以,挪动已有字段的位置会让前端大量代码报错。
总结一下思路:用扩展的Request泛型约束请求输入,用ApiResponse信封规范响应输出,用zod把校验规则和静态类型合二为一,最后用分层组织保证类型可维护。这套组合拳做完,接口层的数据流转就真正处于类型系统的保护之下了。
TypeScriptNode.js请求响应体类型封装修改时间:2026-09-16 05:24:59