分页参数大概是后端接口文档里最不起眼的部分,page、pageSize、sortBy、order,四个字段似乎用几行接口定义就能应付。但真实业务里分页的坑远比想象中多:排序字段传了一个实体上根本不存在的列名,游标分页却还传了page参数,pageSize不设上限导致一次拉取十万条数据。这些问题在运行时固然可以用校验框架拦截,但更优雅的做法是在编译期就把错误堵住。TypeScript的类型系统本身就具备图灵完备的类型级编程能力,完全可以把分页参数的约束直接写进类型定义里,让IDE在你敲下错误字段名的那一刻就报错。本文围绕API响应分页这一具体场景,讲清楚类型级编程怎么落地。

分页参数的类型漏洞从哪里来
先看一段常见的写法,几乎所有项目里都能找到类似的定义:
// 常见的粗糙定义
interface ListParams {
page?: number;
pageSize?: number;
sortBy?: string;
order?: string;
}
interface ListResult<T> {
list: T[];
total: number;
}这段代码能通过编译,但几乎没有提供任何有价值的约束。sortBy的类型是string,意味着调用方可以传入任意字符串,比如把createTime误写成createdTime,编译器毫无反应。order同样是string,实际后端只接受asc和desc,传个ASC就直接被服务端拒绝。更隐蔽的问题在于分页模式没有区分:偏移量分页依赖page加pageSize,游标分页依赖cursor加limit,两者互斥,但上面的类型允许同时传入,也允许一个都不传。
这类问题的本质是把类型定义当成了摆设,只描述字段的存在性,不描述字段的合法值域和字段之间的结构关系。运行时校验库当然能兜底,但它的反馈周期是运行时,而类型约束的反馈周期是编辑器里实时出现的红色波浪线。两者配合使用,类型定义负责开发体验,运行时校验负责安全边界,这才是合理的分工。
用字面量联合与泛型收窄字段取值
第一轮改造,先把可以枚举的取值收窄成字面量联合类型。order字段只有两个合法值,sortBy字段必须属于实体的键集合,这里正好用上keyof运算符配合泛型:
// 排序方向只能是这两个字面量
type SortOrder = 'asc' | 'desc';
// 泛型K被约束为实体T的键
interface Sortable {
field: string;
order: SortOrder;
}
// 让排序字段继承实体的键类型
type SortRule<T> = {
field: keyof T;
order: SortOrder;
};
interface User {
id: number;
name: string;
createdAt: string;
}
// 正确用法,编译通过
const rule1: SortRule<User> = { field: 'createdAt', order: 'desc' };
// 错误用法,编译器直接报错
// const rule2: SortRule<User> = { field: 'createdTime', order: 'asc' };
// 类型“"createdTime"”不能分配给类型“keyof User”这个改动很小,收益却立竿见影。field的类型被约束为keyof T之后,任何拼写错误都会在编译期暴露,而且当实体的字段重命名时,所有引用旧字段名的排序规则都会报错,重构的安全性大幅提升。配合IDE的自动补全,调用方甚至不需要翻接口文档就能知道哪些字段可以排序。
需要注意的是,keyof约束的是第一层的键。如果实体有嵌套结构,需要按嵌套路径排序,就得写一个递归的路径类型,用模板字面量类型拼接路径字符串。这个话题展开较大,一般业务分页场景用一层keyof已经够用,真有嵌套排序需求再引入递归路径类型也不迟。
用判别联合与条件类型区分分页模式
第二轮改造解决分页模式的互斥问题。偏移量分页和游标分页是两种不同的参数结构,用判别联合类型可以让它们天然互斥,再用条件类型让结果类型跟随参数类型变化:
// 两种分页模式,通过mode字段判别
type OffsetPaging = {
mode: 'offset';
page: number;
pageSize: number;
};
type CursorPaging = {
mode: 'cursor';
cursor: string | null;
limit: number;
};
type PagingParams = OffsetPaging | CursorPaging;
// 条件类型:根据模式推导响应结构
type PagingResult<T, P extends PagingParams> =
P extends { mode: 'cursor' }
? { list: T[]; nextCursor: string | null }
: { list: T[]; total: number; page: number; pageSize: number };
// 游标分页的返回值带nextCursor
function query<T, P extends PagingParams>(
params: P & { sort?: SortRule<T> }
): Promise<PagingResult<T, P>> {
return Promise.resolve({ list: [] }) as Promise<PagingResult<T, P>>;
}
// 传偏移量分页参数,total和page都有类型提示
const r1 = query<User, OffsetPaging>({
mode: 'offset', page: 1, pageSize: 20,
sort: { field: 'id', order: 'asc' }
});
// 传游标参数,响应类型自动切换为nextCursor
const r2 = query<User, CursorPaging>({
mode: 'cursor', cursor: null, limit: 20
});判别联合的关键在于mode这个公共字段充当判别器。一旦mode确定为offset,TypeScript的类型收窄机制就只允许出现page和pageSize,传cursor会直接编译报错。反过来游标模式下传page同样非法。这就把两种互斥的分页结构在类型层面彻底隔离开,调用方不可能构造出混杂的参数。
条件类型P extends { mode: 'cursor' } ? ... : ...则让响应类型与请求类型联动。调用方拿到r1时,编辑器知道它有total字段;拿到r2时,编辑器提示的是nextCursor。这种类型层面的联动省去了调用处的类型断言,也让接口封装函数的签名保持单一,不需要为每种分页模式写一个重载。
给pageSize加上类型级的数值约束
最后一个常见需求是限制pageSize的范围,比如后端规定单次最多拉取100条。数值范围的约束在类型层面可以用模板字面量类型做一些文章,但对任意数值做范围校验,纯类型写法会比较繁琐,一种务实的折中方案是:
// 枚举出允许的档位,简单直接
type PageSize = 10 | 20 | 50 | 100;
interface OffsetPaging {
mode: 'offset';
page: number;
pageSize: PageSize;
}
// 或者用品牌类型标记已校验的数字
type Bounded<N extends number> = N & { __brand: 'Bounded' };
function assertPageSize(n: number): Bounded<number> {
if (n < 1 || n > 100) {
throw new RangeError('pageSize必须在1到100之间');
}
return n as Bounded<number>;
}枚举档位的方案适合取值集合固定的场景,缺点是档位之间的数值会被拒绝,灵活性差。品牌类型的思路则是承认运行时校验的必要性,但用类型标记把校验过的值和未校验的值区分开,强制调用方必须经过校验函数才能构造出合法参数。这两种方案各有适用场景,档位固定选前者,范围连续选后者。
综合来看,类型级编程在分页场景的价值不在于炫技,而在于把接口契约里原本只存在于文档中的规则——排序字段必须是实体字段、两种分页模式互斥、pageSize有上限——全部编码进类型定义。编译器替你检查这些规则,接口文档与代码再也不会各说各话。从字面量联合、keyof约束,到判别联合、条件类型,每一层工具对应一类约束需求,按需取用即可在真实项目中稳步提升API调用的类型安全性。
TypeScript类型系统API分页修改时间:2026-09-15 20:12:47