Skip to content

2.4 泛型、class 与类型收窄

本章解决什么问题:写「对任何类型都适用」的函数或容器时,怎样让类型信息不丢失(泛型);怎样把数据和操作数据的方法捆在一起(class);拿到 union、unknown 这类「宽」类型的值时,怎样一步步安全地把它变「窄」(类型收窄)。这是读 Pi 源码前要补齐的最后一批 TypeScript 类型语法——Pi 的 Agent、Session 都是 class,核心函数的签名里到处是泛型,几乎每处错误处理都在做收窄。

前置知识2.2 interface、type 与函数类型2.3 union、字面量与可辨识联合

学习目标:读完本章后你能

  • 解释泛型(Generic)解决什么问题,读懂并写出带类型参数和 extends 约束的函数签名;
  • 写出带构造函数、private / readonly 字段和方法的 class,并说清 class 与 interface 在「运行时是否存在」上的差别;
  • 数出类型收窄的五种常用手段——typeofininstanceof、字面量判别、自定义 type guard——并各举一例;
  • 说出 unknownany 的区别,按固定流程把一个 unknown 值处理成可用的类型;
  • 在 Pi 源码里认出以上全部语法:Resultok / errAgent class、toError

建立直觉

三个话题装进一章,因为它们回答的是同一个问题的三个侧面:类型信息如何在程序里不丢失地流动

  • 泛型像填空题:在函数签名里留一个「类型空位」T,每次调用时由编译器把空位填成具体类型——这次填 number 就全程按 number 检查,下次填 string 就全程按 string 检查;
  • class 像图纸:一张图纸描述「这类东西有哪些数据、会哪些动作」,用 new 照图纸造出任意多个「实例」,每个实例的数据互相独立;
  • 类型收窄像安检:拿到一个「可能是好几种类型」的值,你在代码里出示证据——typeofinstanceof、判别字段——编译器看到证据,才放行你按更具体的类型去使用它。

下面逐个展开。本章示例都可以像 1.2 章那样用 tsx 直接运行;文中出现的编译错误则是用 tsc --noEmit 检查得到的真实输出。

泛型:不丢类型的函数与容器

先看没有泛型会怎样

想写一个「取数组第一个元素」的通用函数。数组里可能装数字、可能装字符串,参数类型写什么?一个诱人的偷懒选项是 2.1 章提过的 any——「任何类型都行,别检查了」。存成 any-crash.ts

ts
function firstOfAny(items: any[]): any {
  return items[0];
}

const first = firstOfAny([10, 20, 30]);
console.log(first.toUpperCase());

toUpperCase 是字符串才有的方法,这里的 first 实际是数字 10——但因为它的类型是 any,编译器完全不拦。tsx any-crash.ts 运行,真实输出(省略了后续调用栈):

TypeError: first.toUpperCase is not a function
    at <anonymous> (…/any-crash.ts:6:19)

这正是 1.1 章说过的「本可编译期发现的错误被推迟到运行时」。any 的问题可以概括成一句话:类型信息进了函数就丢了——传进去的明明是 number[],返回值却成了「什么都可能」的 any

类型参数:把「什么类型」变成参数

泛型的思路是:既然函数的逻辑不关心元素是什么类型,那就把「元素类型」本身变成一个参数,让调用方(实际是编译器)来填。存成 first-item.ts

ts
function firstItem<T>(items: T[]): T | undefined {
  return items[0];
}

const firstNumber = firstItem([10, 20, 30]);
const firstWord = firstItem(["agent", "harness"]);
console.log(firstNumber, firstWord);

预期输出:

10 agent

读这个签名的方法:函数名后的 <T>声明一个类型参数,名字叫 T(习惯用大写单字母或 TValue 这样的形式);后面的 items: T[] 和返回值 T | undefined 是在使用它。调用 firstItem([10, 20, 30]) 时,编译器看到实参是 number[],推断出 T = number,于是这一次调用的返回类型就是 number | undefined——类型信息穿过函数,一点没丢。

图加载中…

图 2.4-1 同一个泛型函数,两次调用各自推断出不同的 T
阅读顺序:两行各自从左到右。同一份 firstItem 代码,T 在每次调用时被独立填充——这就是「填空题」直觉的具体含义。注意返回类型里的 undefined:它来自签名里手写的 T | undefined,对应「数组可能是空的」这一现实。

丢不丢类型,差别在误用时立刻显现。把最后一行改成 console.log(firstNumber.toUpperCase()) 存成 first-item-bad.ts,用 tsc --noEmit --strict --target es2022 first-item-bad.ts 检查,真实输出:

first-item-bad.ts(7,13): error TS18048: 'firstNumber' is possibly 'undefined'.
first-item-bad.ts(7,25): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

同样的误用,any 版本运行时才崩溃,泛型版本在编译期就被抓住——而且顺带连「数组可能为空、返回值可能是 undefined」都一起抓了。

泛型 interface 与泛型容器

类型参数不只能加在函数上,也能加在 2.2 章学过的 interface 和 type 别名上,得到「不丢类型的容器」:

ts
interface Box<T> {
  value: T;
}

const numberBox: Box<number> = { value: 42 };
const wordBox: Box<string> = { value: "agent" };

Box<number>Box<string> 是从同一个模板生成的两个不同类型:前者的 value 只能装数字,后者只能装字符串。其实你早就每天在用泛型容器了——T[] 就是内置泛型类型 Array<T> 的简写。

📘 概念泛型(Generic)
泛型是「把类型本身当作参数」的机制:在函数、interface、type、class 的名字后面用尖括号声明类型参数,使用时(大多由编译器自动推断)填入具体类型。它解决的核心问题是:让「对多种类型通用」的代码不必退化成 any,类型信息从入口保真地流到出口。

约束:extends

有时「任何类型都行」太宽了。比如「返回两个值中更长的那个」,函数体要读 .length,那 T 至少得有 length 属性。这时给类型参数加约束(constraint),关键字是 extends。存成 longest.ts

ts
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

console.log(longest("agent", "pi"));
console.log(longest([1, 2, 3], [4, 5]));

条件 ? 甲 : 乙 是三元表达式:条件成立取甲,否则取乙。预期输出:

agent
[ 1, 2, 3 ]

T extends { length: number } 读作「T 必须至少满足『有一个数字类型的 length 属性』」。字符串、数组都满足,所以前两个调用成立;数字不满足——把 longest(10, 20); 追加到文件尾(存成 longest-bad.ts)再用 tsc 检查,真实报错:

longest-bad.ts(7,9): error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

这是一笔「约束换能力」的交易:约束越强,函数体内能对 T 做的事越多,但能接受的类型越少。读 Pi 源码时你会大量遇到这种带约束的签名,本章末尾就有真例子。

class:把数据和行为装进同一个模板

最小 class

到目前为止,我们用 interface 描述数据的形状、用独立函数操作数据。class 是另一种组织方式:把数据(字段)和操作数据的函数(方法)捆在同一个模板里。它不是 TypeScript 的发明——class 是 JavaScript 语言自带的语法,TypeScript 在它之上加了类型标注。存成 counter.ts

ts
class Counter {
  readonly name: string;
  private count = 0;

  constructor(name: string) {
    this.name = name;
  }

  increment(): number {
    this.count += 1;
    return this.count;
  }
}

const counter = new Counter("tool-calls");
counter.increment();
counter.increment();
console.log(counter.name, counter.increment());

预期输出:

tool-calls 3

逐个认一遍新面孔:

  • 字段namecount 是每个实例自带的数据。count = 0 是声明加初始值,类型由 0 推断为 number
  • constructor(构造函数)new Counter("tool-calls") 时自动执行的初始化函数,参数就是 new 时传的参数;
  • this:在 class 内部指「当前这个实例」,this.count 就是这个实例自己的 count
  • 方法:写在 class 里的函数,通过实例调用(counter.increment())。

new 出多个实例,各自的数据互不干扰——这正是「图纸与机器」的类比。

private 与 readonly

上面还有两个修饰词没解释,它们是 TypeScript 提供的访问控制

  • private count:只允许 class 内部的代码访问。外部拿到实例后不能读写它,防止别人绕过 increment() 直接乱改计数;
  • readonly name:构造完成后不允许再赋值,适合「一旦确定就不该变」的数据。

违反规则会在编译期被抓。把 counter.count = 100;counter.name = "hacked"; 追加到文件尾(存成 counter-bad.ts),tsc 的真实报错:

counter-bad.ts(19,9): error TS2341: Property 'count' is private and only accessible within class 'Counter'.
counter-bad.ts(20,9): error TS2540: Cannot assign to 'name' because it is a read-only property.

注意 privatereadonly 都是编译期的规矩:类型抹掉之后,运行中的对象上这些属性照样存在。它们防的是「同事和三个月后的你写错代码」,不是运行时的恶意访问。

class 与类型的关系

一条 class 声明其实同时创建了两样东西:

  1. 一个运行时的值:构造函数本身,new 要用它,它在编译成 JavaScript 之后依然存在;
  2. 一个类型:实例的类型,可以像任何类型一样用在标注里,比如 const c: Counter = new Counter("x"),或者当函数参数类型。

这和 interface 形成对照:interface 只有第 2 样,编译后彻底消失。这个差别马上会变得重要——下一节的 instanceof 检查之所以能在运行时工作,正因为 class 在运行时真实存在。

⚠️ 常见误解对 interface 用 instanceof
`x instanceof SomeInterface` 是行不通的:instanceof 是运行时检查,而 interface 编译后就没了,运行时根本没有这个名字。instanceof 右边只能放 class(或者说构造函数)。想在运行时判断「某个值符不符合某个 interface 的形状」,要用本章后面讲的 in 检查或自定义 type guard。

class 还可以声明「本 class 实现了某个 interface 的全部要求」,写法是 class A implements B,编译器会逐项核对。另外,类型参数同样可以加在 class 上(比如本章实验里的 Store<TItem>)。先记住这两个形式,Pi 源码里两者都常见。

为什么本书现在才讲 class?因为你马上离不开它了:Pi 的核心对象几乎都是 class——Agent 是 class,会话 Session 是 class,本章末尾就去看它们的源码。

类型收窄:给编译器看证据

2.3 章的 union 类型带来一个新问题:值的类型「宽」的时候,编译器只允许你做所有成员都支持的操作。string | number 类型的值,既不能直接 .trim()(数字没有),也不能直接 .toFixed()(字符串没有)。想用具体类型的能力,必须先收窄(Narrowing):写一段运行时检查,编译器读懂这段检查后,在对应分支里把类型缩小。

TypeScript 的特别之处在于:收窄用的就是普通的 if 语句,编译器会顺着控制流理解你的检查。常用的「证据」有五种。

证据一:typeof——判断基本类型

ts
function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // 这个分支里 id: number
  }
  return id.trim(); // 走到这里,只剩 string 可能
}

typeof 是 JavaScript 自带的运算符,返回 "string""number""boolean""object""function""undefined" 等少数几个字符串。它只能分辨基本类型这一层——所有对象一律返回 "object",再细就无能为力了。还有一个著名的历史包袱:typeof null 也返回 "object",所以判断对象时必须额外排除 null(下文 unknown 一节会用到)。

证据二:in——判断对象有没有某个属性

ts
type FileTarget = { path: string };
type UrlTarget = { url: string };

function describeTarget(target: FileTarget | UrlTarget): string {
  if ("path" in target) {
    return `读取文件 ${target.path}`; // 这个分支里 target: FileTarget
  }
  return `请求网址 ${target.url}`;
}

"path" in target 检查对象上有没有 path 属性,有则收窄到 FileTarget。反引号包住的字符串是模板字符串,${} 里可以嵌入表达式。把 formatIddescribeTarget 合在一个文件里,依次打印 formatId(3.7)formatId(" msg-42 ")describeTarget({ path: "notes.md" })describeTarget({ url: "https://example.com" }),真实输出:

4 msg-42
读取文件 notes.md
请求网址 https://example.com

证据三:instanceof——判断是不是某个 class 的实例

ts
if (value instanceof Error) {
  console.log(value.message); // 这个分支里 value: Error
}

如上一节所说,这依赖 class 在运行时真实存在。Error 是 JavaScript 内置的错误 class(2.9 章展开),Pi 源码里 instanceof Error 出现得非常频繁。

证据四:字面量判别——可辨识联合的专属通道

这是 2.3 章的主角,放进全集里回顾一下:union 的每个成员都带一个字面量类型的判别字段时,比较这个字段就能收窄:

ts
type AgentEvent =
  | { type: "text"; text: string }
  | { type: "tool_call"; toolName: string };

function describe(event: AgentEvent): string {
  if (event.type === "text") {
    return `文本:${event.text}`;
  }
  return `工具调用:${event.toolName}`;
}

顺带一提,x !== undefinedx !== null 这类判空检查也是收窄——前文 firstItem 返回的 T | undefined,就要靠它甩掉 undefined

证据五:自定义 type guard——x is T

前四种证据都是「一次检查收窄一步」。当判断逻辑复杂到需要封装成函数时,问题来了:普通函数返回 boolean,编译器不知道这个 true 意味着什么。类型守卫(Type Guard)就是解法——把返回类型写成 参数名 is 类型

ts
interface ChatMessage {
  role: "user" | "assistant";
  content: string;
}

function isChatMessage(x: unknown): x is ChatMessage {
  return (
    typeof x === "object" &&
    x !== null &&
    "role" in x &&
    (x.role === "user" || x.role === "assistant") &&
    "content" in x &&
    typeof x.content === "string"
  );
}

x is ChatMessage 向编译器承诺:「这个函数返回 true,就代表 xChatMessage」。此后任何地方写 if (isChatMessage(data)),分支里 data 自动收窄。注意这是你向编译器做的承诺:函数体里少查一个字段,编译器照样相信你——写 type guard 要格外仔细。

unknown:安全的「不知道」

最后把收窄用在它最重要的舞台上。程序总有些值在编译期真的不知道是什么类型:JSON.parse 解析外部字符串的结果、catch 捕获的异常(任何值都能被 throw)、通用回调的参数。这种场合有两个选择:

  • any:「不知道,所以别检查」——本章开头你已经见过它的下场;
  • unknown:「不知道,所以什么都不许做,直到你收窄它」。

unknownany 的安全版:可以把任何值赋给 unknown 变量,但在收窄之前,任何属性访问、方法调用、运算都是编译错误。它把「先验明正身、再使用」变成编译器强制执行的纪律。1.1 章提到 Pi 全仓库开启 strict——strict 模式下 catch (e)e 的类型就是 unknown,这不是巧合,而是官方推荐的默认姿势。

处理 unknown 有一套固定流程,本质就是把五种证据按顺序用上。存成 unknown-flow.ts

ts
function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  if (typeof value === "string") return new Error(value);
  return new Error(JSON.stringify(value));
}

try {
  JSON.parse("{这不是合法的 JSON");
} catch (e) {
  console.log(toError(e).message);
}

预期输出(JSON.parse 失败时抛出的真实错误信息,具体措辞随 Node.js 版本可能略有差异):

Expected property name or '}' in JSON at position 1 (line 1 column 2)

toError 把「不知道抛出来的是什么」的值规整成确定的 Error:先用 instanceof 试最常见的情况,再用 typeof 接住字符串,最后兜底转成文本。这个函数不是本书杜撰的教学示例——Pi 源码里有一个几乎一模一样的 toError,马上看到。

图加载中…

图 2.4-2 unknown 值的标准收窄流程
阅读顺序:从上往下。每个菱形是一次运行时检查,也是一次收窄;走到任何叶子节点时,类型都已经具体到可以安全使用。toError 走的是左边「instanceof」和「typeof string」两条路加兜底;本章实验里的 isChatMessage 走的是「in + type guard」那条路。

🌱 初学者提示类型断言 as:最后的手段
你可能在别人的代码里见过 `value as SomeType`——这叫类型断言,意思是「别检查了,我说它是什么就是什么」。它不做任何运行时检查,断言错了就把错误留给运行时,与收窄有本质区别。读 Pi 源码会偶尔遇到 as,但初学阶段的原则很简单:能用收窄就不用 as。

Pi 中哪里用到了它

本章语法在 Pi 里不是点缀,而是骨架。挑三处最有代表性的。

第一处:Resultok / err——泛型和可辨识联合的合体。Pi 对「预期内可能失败的操作」(读文件、执行命令等)不靠抛异常,而是返回一个 Result 值:

earendil-works/pi@c13ffe1第 23–34 行在 GitHub 查看 ↗
Result 类型与它的两个泛型工厂函数。TValue 是成功时装的类型,TError 是失败时装的类型;ok 字段是字面量判别字段。
ts
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };

/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
	return { ok: true, value };
}

一行里集齐了本章和上一章的知识:两个类型参数让成功值和失败值都不丢类型;ok: true | false 的字面量判别让调用方必须先收窄再取值——想拿 value,编译器逼你先证明 oktrue

第二处:Agent 是 class。第 3.5 章会讲 Agent 这个概念,第 6.3 章精读它的实现;现在只看定义头部,你已经能读懂每个修饰词了:

earendil-works/pi@c13ffe1第 171–177 行在 GitHub 查看 ↗
Agent class 的定义头部:private 保护内部状态,readonly 锁定初始化后不再更换的成员。
ts
export class Agent {
	private _state: MutableAgentState;
	private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();
	private readonly steeringQueue: PendingMessageQueue;
	private readonly followUpQueue: PendingMessageQueue;

	public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
	// …(省略:更多公开字段与构造函数)

private _state 把 Agent 的内部状态挡在外部代码之外;private readonly listeners 双重修饰——外部碰不到,内部也不能整个换掉。Set 是 JavaScript 内置的「不重复集合」容器,注意它也是泛型:Set<...> 的尖括号里填的是集合成员的类型。public 是与 private 相对的「外部可访问」,不写修饰词时的默认值就是它。

会话 Session 同样是 class,而且把本章知识全用上了——泛型 class、extends 约束、private 字段:

earendil-works/pi@c13ffe1第 150–157 行在 GitHub 查看 ↗
Session 是带约束类型参数的泛型 class:TMetadata 必须满足 SessionMetadata 的形状,等号后面是不指定时的默认类型。

第三处:toError——unknown 处理的官方姿势。上一节的示例函数在 Pi 里有真身,就住在 Result 隔壁:

earendil-works/pi@c13ffe1第 47–56 行在 GitHub 查看 ↗
Pi 的 toError:用 instanceof 和 typeof 把 unknown 的抛出值规整成 Error,兜底路径还考虑了 JSON.stringify 本身失败的情况。

从源码结构看,这三样东西住在同一个 types.ts 里并被整个 harness 引用,说明「泛型容器 + 收窄取值 + unknown 规整」是 Pi 错误处理的地基,5.6 取消与错误处理会看到它们在完整链路里如何配合。

实践任务

🛠 实践任务用泛型和收窄写一个安全的消息解析器labs/typescript-basics/07-generics

目标:亲手把本章四样东西串成一个小程序:泛型函数、extends 约束、带 private / readonly 的泛型 class、以及「unknown → 收窄 → Result」的完整解析流程。实验目录:labs/typescript-basics/07-generics(全书实验索引见实践任务索引)。

步骤

  1. 进入实验目录,安装依赖并运行:

    sh
    cd labs/typescript-basics/07-generics
    npm install
    npm start
  2. 对照 src/main.ts,找出五种收窄手段各出现在哪一行:typeofininstanceof!== undefined 与判别字段、type guard。

  3. 取消 longest(10, 20) 那行的注释,观察编译器怎样拒绝不满足 extends 约束的调用,再改回去。

  4. 做一个「破坏性实验」:把 isChatMessage 里的 typeof x.content === "string" 一行删掉再运行,喂给它 '{"role":"user"}' 这样缺字段的输入,观察程序行为——体会「type guard 是你向编译器做的承诺,写错了编译器照样相信你」。

预期现象npm start 输出与实验目录下 expected-output.txt 一致:

[1] firstItem: 10 agent
[2] longest: agent
[3] store: session-1 共 2 条消息
[3] 最后一条消息: assistant -> 你好,需要我做什么?
[4] 解析成功: user -> 帮我看看这段代码
[4] 解析失败: JSON 合法,但结构不是一条 ChatMessage
[4] 解析失败: Expected property name or '}' in JSON at position 1 (line 1 column 2)

三条输入走出三种结局:合法消息解析成功;合法 JSON 但结构不对,被 type guard 拦下;根本不是 JSON,被 catch + toError 接住——失败全部作为 Resulterr 分支返回,没有一处裸抛异常。

如何判断成功:输出与预期一致(最后一行措辞可随 Node.js 版本略有差异);第 3 步能读懂报错;第 4 步观察到「编译通过、行为却错了」。

常见错误

  • command not found: tsx:没有先在实验目录里 npm install
  • 直接访问 store.items 或给 store.name 赋值报错:这正是 private / readonly 在工作,不是环境问题;
  • isChatMessage 里先写 "role" in x 再写 typeof x === "object":顺序不能颠倒,unknown 必须先证明是非 null 对象,in 检查才合法。

对应源码位置packages/agent/src/harness/types.tsResult / ok / err / toError(上一节的 SourceRef)。

本章小结

  • 泛型把「类型」变成参数:<T> 声明、调用时由编译器推断填充,让通用函数和容器不丢类型;T extends 约束 用「接受的类型变少」换「函数体内能做的事变多」。
  • class 把字段和方法捆进一个模板,new 出数据独立的实例;private / readonly 是编译期的访问纪律;一条 class 声明同时创建运行时的构造函数和编译期的实例类型,而 interface 只有后者。
  • 类型收窄是「用运行时检查说服编译器」:五种证据——typeofininstanceof、字面量判别(含判空)、自定义 type guard(x is T)。
  • unknown 是安全版的 any:先收窄、后使用,由编译器强制执行;处理流程是 typeof → 排除 null → instanceof / in / type guard → 兜底。
  • Pi 中 Result + ok / err 是泛型与可辨识联合的合体,AgentSession 都是 class,toError 是 unknown 规整的范本。

关键术语:泛型(Generic)、类型参数、约束(extends)、class、构造函数(constructor)、实例、private / readonly、类型收窄(Narrowing)、类型守卫(Type Guard)、unknown、类型断言(as

关键源码索引packages/agent/src/harness/types.tsResult / ok / err / toErrorpackages/agent/src/agent.tsAgentpackages/agent/src/harness/session/session.tsSession

自测问题

  1. function firstItem<T>(items: T[]): T | undefined 与参数类型写成 any[] 的版本,在「调用方误用返回值」这件事上表现有什么不同?
  2. private 字段在编译成 JavaScript 之后还受保护吗?为什么说它和 readonly 防的是「写错代码」而不是「恶意访问」?
  3. 为什么 instanceof 右边只能放 class 而不能放 interface?想在运行时判断一个值是否符合某个 interface 的形状,该用什么?
  4. catch (e) 里的 e 在 strict 模式下是什么类型?写出把它安全变成 Error 的三步(提示:Pi 的 toError)。

下一章预告2.5 异步编程:Promise 与 async/await——类型的地基到此打完,接下来面对 JavaScript 的另一半灵魂:异步。Agent 的一切——调用模型、读写文件、等待工具结果——都是异步操作,Promise 是理解后续所有章节的钥匙。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)