10 Express 与 Sequelize:把类型放在正确边界
对应项目:Customer Controller、Customer Service、Customer Model、Auth Controller。
1. 为什么同一个 customer_id 会有两种类型?
URL 路径 /customers/123
→ req.params.customer_id:通常是 string
→ validator 检查并 toInt
→ matchedData 提取:number
→ Service:接收 number
→ MySQL:按主键查询
不同类型表示不同阶段,不是写重复了。把 Request 的 params 写成 number,不会让 Express 自动解析 URL。
对复杂路由、不同 Express 版本及校验/转换之后的值,不应认为所有 params 永远保持原始字符串;当前路由边界按原始 string 描述,转换结果另用类型。
2. Request 的泛型从左到右怎么读?
当前登录 Controller 使用:
req: Request<EmptyParams, unknown, LoginBody, EmptyQuery>
res: Response<ApiResponse<LoginResultDto>>
这是参数声明片段,不是独立变量赋值。对应位置:
| 参数 | 描述内容 | 当前登录例子 |
|---|---|---|
| Request 第 1 项 | req.params |
没有路径参数 |
| Request 第 2 项 | 响应体相关类型 | unknown,响应由单独 res 的类型约束 |
| Request 第 3 项 | req.body |
登录输入 |
| Request 第 4 项 | req.query |
不接受 query 字段 |
| Request 第 5 项 | locals 类型 | 此处省略,采用库默认 |
| Response 第 1 项 | res.json() 等响应数据 |
ApiResponse<LoginResultDto> |
这让编辑器知道你允许从哪里读什么、返回什么。不是参数越多越安全;没有使用的部分保持简洁,公共输入/输出保留清楚契约。
EmptyParams/EmptyQuery 在项目里是 Record<string, never>,表达不应提供可用字段。它不在运行时拒绝额外 query,是否严格拒绝需要验证规则。
3. Request 类型不验证用户发来的 JSON
interface LoginBody { username: string; password: string }
即使请求参数标成 LoginBody,用户仍可能发送 { username: 123 }。TS 只检查你的源码,不检查网络报文。
当前流程依赖验证链、validateRequest,再进入 Controller。类型声明应准确反映这些真实保证;未经验证的数据可以按 unknown 处理,再收窄。
同理,JSON 中 active:false 与字符串 “false” 不同,不能因为一个接口类型写了 boolean 就认为数据已转换。
4. matchedData<T>():类型不是转换器
当前 Customer 列表:
const options = matchedData<FindCustomersOptions>(req, {
locations: ["query"],
});
const result = await findCustomers(options);
沿用 Controller 的 imports 和 req。转换由验证链中的 toInt/toBoolean 等 sanitizer 完成,matchedData 提取其结果;泛型只是声明你预期的形状。
如果 page 在验证规则中可省略,FindCustomersOptions.page 也应可选。将提取类型写成必填,不会补默认值。
Express 5 的 req.query 是 getter,项目用 matchedData 读取转换后的值,不依赖多次读取 req.query 保留原地修改。
当父对象本身参与校验时,提取可能连带未校验的子字段;Controller 与 Service 仍需显式写入白名单。参见 验证答疑。
5. Model 类型不是 DTO,也不是创建输入
CreateCustomerBody:调用者可以提供的字段
Customer:ORM 实例、数据库字段及操作能力
CustomerListItemDto:API 公开的数据表示
当前 Model 的 active 是 number,DTO 是 boolean;create_date 是 Date,DTO 是 ISO string;地址关联要组织成响应结构。
三个契约不完全相同,适度重复是表达边界,而不是需要自动消除的缺陷。
6. declare、CreationOptional、NonAttribute
declare customer_id: CreationOptional<number>;
declare first_name: string;
declare address?: NonAttribute<Address>;
这是模型类里的类型声明:
- declare:声明属性类型,不生成普通类字段初始化代码;Sequelize 负责实例字段访问。
- CreationOptional:这是模型列,但创建时可能由自增/默认机制提供;不自己创建 SQL 默认值。
- NonAttribute:这是关联/非列数据,排除出 InferAttributes 的列推断。
?:实例可能没有加载这个关联。
不要随意改成 first_name!: string 的普通字段定义来消红线。根据 TS 类字段输出方式,它可能在运行时生成字段并覆盖 ORM 的属性访问机制。当前 Sequelize 模型使用 declare,是类型与运行时职责分离的体现。
SQL 可空、创建可省略、本次未加载关联是三种不同状态,不能都用 CreationOptional 代替。
7. attributes 不自动缩窄返回的实例类型
const customer = await Customer.findByPk(1, {
attributes: ["customer_id"],
});
TS 仍可能让你访问 first_name,但本次 SQL 没选它。模型整体定义不是每次查询的精确投影。
做法:字段清单与 mapper 靠近、复用投影、集中检查必需关联,并用真实查询测试。只加 as LoadedCustomer 不会补出数据。
8. db.query<T>() 也不是 SQL 类型解析器
import { QueryTypes } from "sequelize";
import db from "../../db";
interface CustomerAggregateRow {
payment_total: string;
open_rental_count: string;
}
const rows = await db.query<CustomerAggregateRow>(`
SELECT
(SELECT COALESCE(SUM(amount), 0) FROM payment
WHERE customer_id = :customer_id) AS payment_total,
(SELECT COUNT(*) FROM rental
WHERE customer_id = :customer_id AND return_date IS NULL) AS open_rental_count
`, {
type: QueryTypes.SELECT,
replacements: { customer_id: 1 },
});
泛型描述一行,API 返回行数组。误写 CustomerAggregateRow[] 会让类型形状偏离真实数据。
TS 不解析 SQL alias、校验 DECIMAL/BIGINT 返回类型或日期时区。该类型要靠数据库配置和真实结果验证;外部来源不可靠时加必要的运行时检查。
9. ApiResponse 的 code 不一定能完成分支收窄
项目当前结构近似:
type ResponseShape<T> =
| { code: "OK"; data: T }
| { code: string; data?: null };
失败分支 string 也包含 “OK”。因此判断 code===“OK” 不一定排除失败分支,data 仍可能带 null/undefined。
一个互斥的教学设计:
type Result<T> =
| { ok: true; data: T }
| { ok: false; message: string };
function unwrap<T>(result: Result<T>): T {
if (result.ok) return result.data;
throw new Error(result.message);
}
也可以用有限且互不重叠的成功/错误码联合。不能用 Exclude<string, "OK"> 真正表达“任意字符串但排除 OK”。
这里解释类型边界,不修改现有 HTTP 响应协议。公共协议变更需要考虑已有客户端。
10. 哪层证明哪些事实?
| 层次 | 应提供的保证 |
|---|---|
| TS | 代码中的字段、类型和返回路径一致 |
| 验证链 | 外部输入真实符合规则,并按约定转换 |
| Service | 业务状态、默认值、白名单和事务策略 |
| MySQL 约束 | 唯一性、外键与适用的数据库规则 |
| DTO mapper | 对外字段与表示明确,不泄漏内部数据 |
| 测试 | 上述约定与真实运行行为吻合 |
11. 练习
- 画出 customer_id 从 URL 到 DTO 的类型变化。
- 故意漏 toInt,观察 matchedData 泛型为什么仍不能证明值已是数字。
- 减少一个 attributes 字段,确认 mapper 测试能发现遗漏。
- 比较重叠 code 分支与互斥 ok 分支的收窄表现。
下一章:类型组织与配置。