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>;

这是模型类里的类型声明:

不要随意改成 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. 练习

  1. 画出 customer_id 从 URL 到 DTO 的类型变化。
  2. 故意漏 toInt,观察 matchedData 泛型为什么仍不能证明值已是数字。
  3. 减少一个 attributes 字段,确认 mapper 测试能发现遗漏。
  4. 比较重叠 code 分支与互斥 ok 分支的收窄表现。

下一章:类型组织与配置。