第4章:请求校验 —— 类型即校验与参数来源
约 4 分钟 · 更新于 2026-09-02
在 Express 里,TypeScript 类型是给编译器看的注释,运行时什么也不保证;在 Encore.ts 里,接口类型本身就是运行时校验 schema,且在 Rust 层执行。本章讲清"类型即校验"的完整规则与四种参数来源。
一、双重保障:编译时 + 运行时
typescript
import { api } from "encore.dev/api";
interface SearchRequest {
depCity: string;
arrCity: string;
date: string;
}
export const search = api(
{ method: "POST", path: "/search", expose: true },
async (req: SearchRequest): Promise<SearchResponse> => {
// 进入这里时,req 三个字段一定存在且是 string —— 不需要手写判空
},
);
- 编译时:调用方(服务间调用、生成的客户端)传错类型直接编译失败;
- 运行时:外部 HTTP 请求由 Rust 层按接口 schema 校验,缺字段/类型不符返回 400,请求不会进入 JavaScript 处理函数。
这带来两个工程含义:处理函数里不再需要防御性判空样板;恶意构造的畸形请求在 Rust 层就被挡掉,不消耗 JS 事件循环(顺带缓解 DoS)。
响应方向的校验只在编译时进行(你自己的代码返回什么类型编译器管得住,无需运行时再验)。
二、支持的类型
基本类型与结构
typescript
interface Request {
name: string; // 字符串
age: number; // 数字(int/float)
vip: boolean; // 布尔
tags: string[]; // 数组
passengers: { name: string; idNo: string }[]; // 对象数组
mixed: (string | number)[]; // 多类型数组
cabin: "economy" | "business" | "first"; // 字符串字面量枚举
}
修饰符
typescript
interface Request {
remark?: string; // 可选:缺失时为 undefined
couponId: string | null; // 可空:必须传,但可以是 null
}
?(可选)与 | null(可空)语义不同:可选是"可以不传",可空是"必须传但值可为 null"。两者可组合。
三、校验器:叠加约束
从 encore.dev/validate 导入校验器,用交叉类型 & 叠在字段上:
typescript
import { api } from "encore.dev/api";
import {
Min, Max, MinLen, MaxLen,
IsEmail, IsURL, StartsWith, EndsWith, MatchesRegexp,
} from "encore.dev/validate";
interface CreatePassengerRequest {
email: string & IsEmail; // 合法邮箱
name: string & MinLen<1> & MaxLen<64>; // 1–64 字符
age: number & Min<0> & Max<120>; // 0–120
homepage?: string & IsURL; // 可选;若传必须是 URL
ticketNo: string & MatchesRegexp<"^[0-9]{3}-[0-9]{10}$">; // 电子票号格式
orderId: string & StartsWith<"ORD_">; // 前缀约束
}
校验器全表
| 校验器 | 适用类型 | 示例 |
|---|
| Min<N> / Max<N> | number | price: number & Min<0> |
| MinLen<N> / MaxLen<N> | string、array | segments: Segment[] & MaxLen<4> |
| IsEmail | string | contact: string & IsEmail |
| IsURL | string | callback: string & IsURL |
| StartsWith<S> / EndsWith<S> | string | file: string & EndsWith<".pdf"> |
| MatchesRegexp<R> | string | iata: string & MatchesRegexp<"^[A-Z]{3}$"> |
组合逻辑:& 与 |
typescript
// 且:全部通过
count: number & (Min<3> & Max<1000>);
// 或:至少一个通过
contact: string & (IsURL | IsEmail);
// 混合:5–100 字符 且 是 URL
website: string & MinLen<5> & MaxLen<100> & IsURL;
校验失败统一返回 400:
json
{
"code": "invalid_argument",
"message": "validation failed",
"details": { "field": "email", "error": "must be a valid email" }
}
四、四种参数来源
同一个请求接口里,不同字段可以来自 HTTP 请求的不同位置,用包装类型显式声明:
typescript
import { api, Query, Header, Cookie } from "encore.dev/api";
interface ListOrdersRequest {
// 1. 路径参数:由 path 里的 :status 自动映射(同名字段)
status: string;
// 2. 查询串:?limit=20&offset=0
limit?: Query<number>;
offset?: Query<number>;
// 3. 请求头
clientVersion: Header<"X-Client-Version">;
// 4. Cookie
session?: Cookie<"session">;
// 5. 其余字段 → 请求体(有 body 的方法)
filterRemark?: string;
}
export const listOrders = api(
{ method: "POST", path: "/orders/:status", expose: true },
async (req: ListOrdersRequest): Promise<ListOrdersResponse> => { /* ... */ },
);
默认归属规则
| 方法 | 未加包装类型的字段默认去向 |
|---|
| POST / PUT / PATCH | 请求体(JSON) |
| GET / HEAD / DELETE | 查询串 |
要点:
- 路径参数不需要包装类型——path 里的 :name 段自动映射到接口同名字段;
- Query<T> 在有 body 的方法里把字段显式移到查询串;GET 类方法里普通字段本来就走查询串,Query<> 可省;
- Header<"名字"> 的泛型参数是真实的 HTTP 头名,字段名本身随意;
- 嵌套对象内部的 Query / Header 无效——包装类型只在顶层字段生效,嵌套里会被当普通 body 字段。
响应方向
响应接口里的 Header<> / Cookie<> 同样有效:字段值会写进响应头/Set-Cookie,其余字段序列化为 JSON body。设置登录 Cookie 就是"在响应接口里放一个 Cookie<"session"> 字段"。
五、和 Zod 系方案的对比
| 维度 | Encore.ts | Express + Zod |
|---|
| schema 定义 | TypeScript 接口本身 | 另写一份 Zod schema(与类型二选一或用 infer 派生) |
| 执行位置 | Rust 层,进 JS 前 | JS 层,已进事件循环 |
| 文档同步 | 自动(同一来源) | 需要 zod-to-openapi 等桥接 |
| 客户端类型 | 自动生成 | 手动导出/维护 |
| 校验表达力 | 常用约束(表见上) | 任意自定义逻辑 |
Encore 覆盖了 90% 的常规校验场景;真正复杂的业务校验(如"往返日期不得早于去程")仍写在处理函数里,用 APIError.invalidArgument 抛出——结构校验交给框架,业务校验留在代码,边界清晰。
六、常见误区
- 以为 TypeScript 类型运行时会被擦除所以"还得自己验"——Encore 恰恰把接口编译成了 Rust 层校验器,这是它和裸 TS 最大的区别。
- 在嵌套对象里用 Query<> / Header<>——只在顶层生效,嵌套里静默退化为 body 字段。
- 分不清 ? 与 | null——可选是可缺席,可空是必须到场但可为 null。
- 用正则校验器写超复杂业务规则——MatchesRegexp 适合格式校验(IATA 码、票号),跨字段业务规则进处理函数。
- GET 接口给字段包 Query<> 以为必须——GET 的普通字段默认就走查询串。
- 忘记校验失败根本不会进处理函数——在 handler 里打日志排查"为什么没收到请求"是徒劳,去看 400 响应体。
七、实践练习
- 为"航班搜索"接口建模:depCity/arrCity 用 MatchesRegexp<"^[A-Z]{3}$"> 约束 IATA 三字码,date 约束 ^\d{4}-\d{2}-\d{2}$,cabin 用字面量枚举。用 curl 分别构造合法与非法请求,观察 400 响应。
- 写一个分页列表接口:limit(Query<number> & Min<1> & Max<100>)、offset(Query<number> & Min<0>),验证越界返回 400。
- 把客户端版本从 X-Client-Version 头读进接口,在响应里通过 Header<"X-Server-Time"> 回写服务器时间。
- 设计乘机人接口:idNo 与 passportNo 至少传一个——先试试类型层能否表达,体会"结构校验/业务校验"的边界(答案:这属于业务校验,进处理函数)。
八、总结
- 接口类型 = 校验 schema = 文档 = 客户端类型,一处定义四处生效。
- 校验在 Rust 层执行,非法请求不进入 JavaScript——性能与安全双收益。
- 九个校验器覆盖常规约束,&/| 组合表达且/或逻辑。
- 参数来源四通道:路径自动映射、Query<>、Header<>、Cookie<>,其余按方法默认走 body 或查询串;包装类型只在顶层生效。
- 结构校验交框架、业务校验留代码,是使用 Encore 校验体系的正确姿势。
请继续阅读:第5章:数据库。
原始资料引用