F12第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

泛型与类型收窄

泛型与类型收窄 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这一课在基本类型之上加两个杠杆:泛型(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[]。
  • 验证两种检查的距离:喂一段「编译通过」的断言代码一个错误形状的数据——编译器一声不吭,运行时炸掉。

动手任务

打开本课实验区:

  1. 写 emptyPage<T>(pageSize: number): Page<T>,返回 total 为 0 的空页,用它初始化状态——全程不需要 any。
  2. 给 LoadState 加 { status: 'saving' } 分支,再删掉 render 的 default,观察编译器如何提醒你漏处理了哪个状态。
  3. 把实验区里「绕过 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 在哪里被确定
  • 状态建模用可辨识联合,分支里类型自动收窄、不写断言
  • 能说出编译检查与运行时校验各自防什么,边界处用哪个

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 1 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码较难

阅读代码(TypeScript,strict 模式)。user.name 那一行会怎样?

type User = { id: number; name: string };

function findUser(users: User[], id: number): User | null {
  return users.find((u) => u.id === id) ?? null;
}

const users: User[] = [{ id: 1, name: '阿明' }];
const user = findUser(users, 1);

if (typeof user === 'object' && user !== null) {
  console.log(user.name.toUpperCase());
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...