泛型与类型收窄
泛型与类型收窄 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这一课在基本类型之上加两个杠杆:泛型(generics,让类型像参数一样传入,一份逻辑适配多种类型)和类型收窄的系统用法。读完你应该能写出类型安全的分页结果 Page<T>,用可辨识联合描述加载状态并让 switch 自动收窄,还能说清「编译检查通过」和「运行时正确」的距离。
问题场景
课程列表要分页,学习记录也要分页。第一版复制粘贴出 PageCourse、PageRecord 两个几乎一样的类型,第三处分页出现时你又复制了一遍——而且接口偶尔返回 items: null,前两版都没防住。泛型解决复制粘贴的问题,可辨识联合加运行时校验解决脏数据的问题。
核心解释
泛型把「类型」变成参数,T 是占位符,使用时才确定:
interface Page<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}
function paginate<T>(items: T[], page: number, pageSize: number): Page<T> {
const start = (page - 1) * pageSize;
return {
items: items.slice(start, start + pageSize),
page,
pageSize,
total: items.length,
};
}
const coursePage = paginate(courses, 1, 10); // 自动推出 Page<Course>
const recordPage = paginate(records, 1, 20); // 同一份逻辑,Page<StudyRecord>
paginate(courses, ...) 不用写 paginate<Course>,TS 会从参数自动推出 T。返回值一旦是 Page<Course>,coursePage.items[0].title 就有完整的提示和检查。
状态用可辨识联合(discriminated union,每个分支共享一个字面量类型的「标签」字段)建模,收窄会自动发生:
type LoadState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Page<T> }
| { status: 'error'; message: string };
function render(state: LoadState<Course>): string {
switch (state.status) {
case 'loading': return '加载中…';
case 'success': return `共 ${state.data.total} 门课`; // 只有这个分支有 data
case 'error': return state.message;
default: return '还没开始';
}
}
在 case 'success' 里访问 state.data 合法;在别的分支里写 state.data 直接报错——类型跟着标签走。
示例拆解
编译检查管不到网络返回的脏数据,「边界校验 + 收窄」才是完整防线:
function isCourse(x: unknown): x is Course {
return (
typeof x === 'object' && x !== null &&
typeof (x as Course).id === 'number'
);
}
function parsePage(raw: unknown): Page<Course> | null {
if (typeof raw !== 'object' || raw === null) return null;
const p = raw as Record<string, unknown>; // 边界处允许一次断言,随后立刻校验
if (!Array.isArray(p.items) || typeof p.total !== 'number') return null;
return { items: p.items.filter(isCourse), page: 1, pageSize: 10, total: p.total };
}
- 断言(
as,告诉编译器「按这个类型算」)是「我说了算」的指令,不是证明。原则:只在边界用,用完马上用运行时检查兑现承诺。 x is Course是类型谓词(type predicate,自定义收窄函数的返回标注),让 filter 之后的数组自动变成Course[]。- 验证两种检查的距离:喂一段「编译通过」的断言代码一个错误形状的数据——编译器一声不吭,运行时炸掉。
动手任务
打开本课实验区:
- 写
emptyPage<T>(pageSize: number): Page<T>,返回 total 为 0 的空页,用它初始化状态——全程不需要 any。 - 给 LoadState 加
{ status: 'saving' }分支,再删掉 render 的 default,观察编译器如何提醒你漏处理了哪个状态。 - 把实验区里「绕过 parsePage 直接 as」的错误示范改成走校验函数,喂一个
items: null的脏数据,确认它返回 null 而不是崩溃。
常见错误
- 泛型写成 any:
function first(arr: any): any丢了所有检查;写成function first<T>(arr: T[]): T | undefined才有完整推断链。 - 收窄后又加多余的 if:
case 'success'里再判if (state.data)对编译器是废话——要防的是运行时数据,那应该在边界校验。 - 断言当默认工具:
(res as Page<Course>)一句断言省掉所有校验,接口一改全盘崩;断言留给边界,且必须紧跟运行时检查。 - 标签字段用数字:可辨识联合的标签用字符串字面量('loading')才好读好收窄,用 0/1/2 会回到魔法数字时代。
检查与复盘
完成下方检查题确认理解。自查清单:
- 能写出 Page<T> 并说清 T 在哪里被确定
- 状态建模用可辨识联合,分支里类型自动收窄、不写断言
- 能说出编译检查与运行时校验各自防什么,边界处用哪个
参考资料
- TypeScript 官方手册:泛型 —— 泛型函数与泛型类型
- TypeScript 官方手册:类型收窄 —— 判断、可辨识联合与类型谓词
- TypeScript:建议与禁忌 —— 什么时候不该用断言
读完这一课了?
标记完成后,工作台会安排接下来的学习。