Skip to content
签到签到

第 2 章:TypeScript 类型系统与结构化设计

本章定位

资深前端要把 TypeScript 当作设计工具,而不是给普通对象加 interface。本章重点是用判别联合、泛型、条件类型、branded type 和运行时 Schema 表达业务边界。

必须掌握清单

必须掌握

  • 能用判别联合表达互斥状态,而不是依赖大量可选字段。

    相关答案: 判别联合为每种状态定义共同的字面量判别字段,使分支收窄后只能访问该状态合法的数据,并可用 never 做穷尽检查。大量可选字段会允许“成功却没有数据”或“成功和错误同时存在”等非法组合。

    具体例子: 请求状态拆成 { type: 'loading' }{ type: 'success'; data: User }{ type: 'error'; error: Error } 后,只有 success 分支能读取 data,新增状态时编译器还能提醒补充分支。

  • 能用泛型约束和条件类型减少重复类型。

    相关答案: 泛型约束描述类型参数至少应具备的结构,条件类型根据输入类型计算输出类型;两者适合提取多个接口共有的类型关系,而不是用 any 抹掉差异或复制多份近似声明。

    具体例子: getById<T extends { id: string }>(items: T[], id: string): T | undefined 可同时服务用户和工具列表;通用 ApiResult<T> 也能根据 T 是否为分页数据推导分页元信息。

  • 能解释 infer 的真实使用场景。

    相关答案: infer 只能出现在条件类型中,用于从一个已知复合类型里声明并提取局部类型。它适合封装通用类型工具,不是运行时推断,也不应为了炫技替代清晰的显式类型。

    具体例子: type AsyncValue<T> = T extends Promise<infer R> ? R : T 能从 Promise<User> 提取 User;框架类型也常用它从函数或事件处理器中提取返回值和参数。

  • 能用 branded type 区分跨边界 id。

    相关答案: TypeScript 的结构化类型会把所有字符串视为可互换,branded type 通过仅存在于类型层的品牌字段增加名义差异。品牌值应在解析、校验或领域对象创建的边界产生,而不是在业务代码中随意断言。

    具体例子:SessionIdToolId 声明为不同品牌后,loadSession(toolId) 会在编译期报错,即使二者在运行时都是字符串。

  • 能区分编译期类型安全和运行时 JSON Schema 校验。

    相关答案: TypeScript 类型在编译后会被擦除,只能检查开发期代码;HTTP 请求、localStorage、模型输出等外部数据到达运行时后仍可能不合法,必须通过 JSON Schema、Zod 等校验后才能转成可信领域类型。

    具体例子: 接口声称返回 User 并不能阻止服务端漏传 name。客户端先用 schema 校验 response.json(),失败时返回结构化错误,成功后再交给依赖 User 的组件。

原理讲解

TypeScript 主要采用结构化类型,但结构化类型不能阻止两个语义不同的字符串被混用。branded type 通过给类型增加一个编译期标签,让 sessionIdtoolId 不能直接互相赋值。

判别联合的核心是让 type 字段成为判断分支的依据。它比统一的 type: string 更安全,因为 switchif 收窄后,编译器能推断每个分支的具体字段。

运行时边界需要单独校验。TypeScript 类型在编译后消失,模型输出、工具 JSON、HTTP 请求体等不可信数据必须用 zod 或 JSON Schema 验证。

代码示例或模板

ts
type Brand<T, Name extends string> = T & { readonly __brand: Name };
type SessionId = Brand<string, "SessionId">;
type ToolId = Brand<string, "ToolId">;

type SessionEvent =
  | { type: "user/message"; id: string; text: string }
  | { type: "assistant/message"; id: string; text: string }
  | { type: "tool/call"; id: string; toolId: ToolId; input: unknown }
  | { type: "tool/result"; id: string; toolId: ToolId; output: unknown }
  | { type: "error"; id: string; message: string };

function renderEvent(event: SessionEvent): string {
  switch (event.type) {
    case "user/message":
      return `用户: ${event.text}`;
    case "assistant/message":
      return `助手: ${event.text}`;
    case "tool/call":
      return `调用工具: ${event.toolId}`;
    case "tool/result":
      return `工具结果: ${event.toolId}`;
    case "error":
      return `错误: ${event.message}`;
  }
}

renderEvent 的返回值会被 TypeScript 检查为完整,如果新增事件类型,编译器会提示遗漏分支。

AI Agent 概念对照

  • Session Event 对应判别联合。
  • Tool Definition 对应输入 Schema 和输出 Contract。
  • 模型输出对应运行时 JSON 校验。
  • 声明合并对应 Agent 事件类型扩展。

面试追问

1. 为什么工具结果更适合判别联合,而不是统一对象加可选字段?

查看深度解析

标准回答: 成功、失败、取消等结果互斥,判别联合能让每个分支只携带合法字段,并在检查判别字段后精确收窄。统一对象加可选字段会允许 ok: true 却没有 data,或 data 与 error 同时存在。

原理展开: 类型系统应表达领域不变量,而不是把组合合法性留给注释。配合 switchnever,新增结果类型时能发现遗漏处理。

工程示例: ToolResult = { type: 'success'; data: T } | { type: 'error'; error: ToolError } | { type: 'cancelled'; reason: string },渲染器的每个分支字段都明确。

常见误区: 判别字段仍声明成宽泛 string,导致无法收窄;或在每个分支重复大量无关字段。

继续追问: 服务端 JSON 怎么安全转成这个联合?

回答方向: 在边界用 schema 按判别字段校验,再返回领域联合;类型断言不能替代运行时验证。

2. infer 在工具参数类型推导中有什么作用?

查看深度解析

标准回答: infer 在条件类型的匹配位置声明待提取类型,可从工具定义、schema 包装器或执行函数签名中提取参数和返回值,让注册、调用和 UI 共用一份类型来源。

原理展开: 它是编译期模式匹配,不会读取运行时值。适合封装通用库类型,业务类型能直接写清时不必强行使用。

工程示例: type ToolInput<T> = T extends ToolDefinition<infer I, any> ? I : never,注册 searchTool 后调用方自动得到 { query: string }

常见误区:infer 解释成 TypeScript 的一般类型推断,或在无法匹配的条件类型中期望它产生结果。

继续追问: 如何同时推导异步返回值?

回答方向: 先从执行函数提取 ReturnType,再用 Awaited 或条件类型解包 Promise;保留错误通道为显式联合。

3. 为什么跨边界 id 不应该裸用 string?

查看深度解析

标准回答: sessionIdtoolIdrequestId 在运行时都可能是字符串,但语义完全不同。裸 string 无法阻止误传,branded type 能在编译期增加名义区别,减少跨模块和跨接口接线错误。

原理展开: TypeScript 是结构化类型系统,品牌通过不可构造或约定字段制造差异;品牌只能防止程序内部混用,不能验证外部字符串格式。

工程示例: loadSession(toolId) 在使用不同品牌后直接编译失败;HTTP 参数经 UUID schema 验证后由工厂函数创建 SessionId

常见误区: 到处用 as SessionId 绕过入口校验,或认为品牌字段真实存在并能用于序列化。

继续追问: 品牌类型会不会污染 API?

回答方向: 只在领域边界和公共接口使用,序列化仍是 string;提供集中解析、创建和格式化函数,避免调用方手工断言。

4. TypeScript 类型检查能替代 JSON Schema 吗?

查看深度解析

标准回答: 不能。TypeScript 只在编译期检查受控源码,类型会被擦除;HTTP、存储、用户输入和模型工具参数都是运行时不可信数据,必须用 JSON Schema、Zod 等校验。

原理展开: 静态类型保证“通过验证后的值如何被代码使用”,运行时 schema 保证“外部值是否能进入可信域”。最佳实践是让两者来自同一来源或做一致性测试。

工程示例: 工具声明输入 schema 为 { path: string },模型传入数字时在执行前返回结构化校验错误,而不是让文件工具内部崩溃。

常见误区:response.json() 标注泛型就认为数据已验证;泛型只改变编译器视角。

继续追问: 类型和 schema 重复维护怎么办?

回答方向: 选择 schema-first 推导 TypeScript,或用可生成 schema 的受限类型源;无论方向如何,都在 CI 加契约样例验证。

5. 如何在没有运行时库的情况下让联合类型覆盖完整?

查看深度解析

标准回答: 对判别联合使用 switch,在 default 中把剩余值赋给 never,或调用只接受 neverassertNever。新增成员却未处理时,编译器会在穷尽检查处报错。

原理展开: 每处理一个 case,控制流分析都会排除对应成员;所有成员覆盖后剩余类型才是 never。这保证源码分支完整,但不验证运行时输入确属该联合。

工程示例: Session Event 新增 tool_cancelled 后,旧渲染函数的 const exhaustive: never = event 失败,迫使开发者补充 UI。

常见误区: default 直接返回空值或抛通用错误,这会吞掉编译期遗漏;判别字段若是 string 也无法穷尽。

继续追问: assertNever 在生产环境还需要抛错吗?

回答方向: 需要防御由旧数据或未校验输入造成的运行时未知分支,并记录原始类型;编译期与运行时保护各解决一层问题。

实践任务

定义一套包含用户消息、助手消息、工具调用、工具结果和错误的 Session Event 类型,并写一个不会遗漏分支的渲染函数。

验收标准

  • 新增一种事件类型时,编译期能发现遗漏分支。
  • 能区分跨边界的 id 类型。
  • 能说明哪些数据必须额外运行时校验。

关联材料