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 在「运行时是否存在」上的差别;- 数出类型收窄的五种常用手段——
typeof、in、instanceof、字面量判别、自定义 type guard——并各举一例;- 说出
unknown与any的区别,按固定流程把一个unknown值处理成可用的类型;- 在 Pi 源码里认出以上全部语法:
Result与ok/err、Agentclass、toError。
建立直觉
三个话题装进一章,因为它们回答的是同一个问题的三个侧面:类型信息如何在程序里不丢失地流动。
- 泛型像填空题:在函数签名里留一个「类型空位」
T,每次调用时由编译器把空位填成具体类型——这次填number就全程按number检查,下次填string就全程按string检查; - class 像图纸:一张图纸描述「这类东西有哪些数据、会哪些动作」,用
new照图纸造出任意多个「实例」,每个实例的数据互相独立; - 类型收窄像安检:拿到一个「可能是好几种类型」的值,你在代码里出示证据——
typeof、instanceof、判别字段——编译器看到证据,才放行你按更具体的类型去使用它。
下面逐个展开。本章示例都可以像 1.2 章那样用 tsx 直接运行;文中出现的编译错误则是用 tsc --noEmit 检查得到的真实输出。
泛型:不丢类型的函数与容器
先看没有泛型会怎样
想写一个「取数组第一个元素」的通用函数。数组里可能装数字、可能装字符串,参数类型写什么?一个诱人的偷懒选项是 2.1 章提过的 any——「任何类型都行,别检查了」。存成 any-crash.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:
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 别名上,得到「不丢类型的容器」:
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> 的简写。
约束:extends
有时「任何类型都行」太宽了。比如「返回两个值中更长的那个」,函数体要读 .length,那 T 至少得有 length 属性。这时给类型参数加约束(constraint),关键字是 extends。存成 longest.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:
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逐个认一遍新面孔:
- 字段:
name和count是每个实例自带的数据。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.注意 private 和 readonly 都是编译期的规矩:类型抹掉之后,运行中的对象上这些属性照样存在。它们防的是「同事和三个月后的你写错代码」,不是运行时的恶意访问。
class 与类型的关系
一条 class 声明其实同时创建了两样东西:
- 一个运行时的值:构造函数本身,
new要用它,它在编译成 JavaScript 之后依然存在; - 一个类型:实例的类型,可以像任何类型一样用在标注里,比如
const c: Counter = new Counter("x"),或者当函数参数类型。
这和 interface 形成对照:interface 只有第 2 样,编译后彻底消失。这个差别马上会变得重要——下一节的 instanceof 检查之所以能在运行时工作,正因为 class 在运行时真实存在。
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——判断基本类型
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——判断对象有没有某个属性
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。反引号包住的字符串是模板字符串,${} 里可以嵌入表达式。把 formatId 和 describeTarget 合在一个文件里,依次打印 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 的实例
if (value instanceof Error) {
console.log(value.message); // 这个分支里 value: Error
}如上一节所说,这依赖 class 在运行时真实存在。Error 是 JavaScript 内置的错误 class(2.9 章展开),Pi 源码里 instanceof Error 出现得非常频繁。
证据四:字面量判别——可辨识联合的专属通道
这是 2.3 章的主角,放进全集里回顾一下:union 的每个成员都带一个字面量类型的判别字段时,比较这个字段就能收窄:
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 !== undefined、x !== null 这类判空检查也是收窄——前文 firstItem 返回的 T | undefined,就要靠它甩掉 undefined。
证据五:自定义 type guard——x is T
前四种证据都是「一次检查收窄一步」。当判断逻辑复杂到需要封装成函数时,问题来了:普通函数返回 boolean,编译器不知道这个 true 意味着什么。类型守卫(Type Guard)就是解法——把返回类型写成 参数名 is 类型:
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,就代表 x 是 ChatMessage」。此后任何地方写 if (isChatMessage(data)),分支里 data 自动收窄。注意这是你向编译器做的承诺:函数体里少查一个字段,编译器照样相信你——写 type guard 要格外仔细。
unknown:安全的「不知道」
最后把收窄用在它最重要的舞台上。程序总有些值在编译期真的不知道是什么类型:JSON.parse 解析外部字符串的结果、catch 捕获的异常(任何值都能被 throw)、通用回调的参数。这种场合有两个选择:
any:「不知道,所以别检查」——本章开头你已经见过它的下场;unknown:「不知道,所以什么都不许做,直到你收窄它」。
unknown 是 any 的安全版:可以把任何值赋给 unknown 变量,但在收窄之前,任何属性访问、方法调用、运算都是编译错误。它把「先验明正身、再使用」变成编译器强制执行的纪律。1.1 章提到 Pi 全仓库开启 strict——strict 模式下 catch (e) 里 e 的类型就是 unknown,这不是巧合,而是官方推荐的默认姿势。
处理 unknown 有一套固定流程,本质就是把五种证据按顺序用上。存成 unknown-flow.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」那条路。
Pi 中哪里用到了它
本章语法在 Pi 里不是点缀,而是骨架。挑三处最有代表性的。
第一处:Result 与 ok / err——泛型和可辨识联合的合体。Pi 对「预期内可能失败的操作」(读文件、执行命令等)不靠抛异常,而是返回一个 Result 值:
Resultexport 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,编译器逼你先证明 ok 是 true。
第二处:Agent 是 class。第 3.5 章会讲 Agent 这个概念,第 6.3 章精读它的实现;现在只看定义头部,你已经能读懂每个修饰词了:
Agentexport 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 字段:
第三处:toError——unknown 处理的官方姿势。上一节的示例函数在 Pi 里有真身,就住在 Result 隔壁:
toError从源码结构看,这三样东西住在同一个 types.ts 里并被整个 harness 引用,说明「泛型容器 + 收窄取值 + unknown 规整」是 Pi 错误处理的地基,5.6 取消与错误处理会看到它们在完整链路里如何配合。
实践任务
labs/typescript-basics/07-generics目标:亲手把本章四样东西串成一个小程序:泛型函数、extends 约束、带 private / readonly 的泛型 class、以及「unknown → 收窄 → Result」的完整解析流程。实验目录:labs/typescript-basics/07-generics(全书实验索引见实践任务索引)。
步骤:
进入实验目录,安装依赖并运行:
shcd labs/typescript-basics/07-generics npm install npm start对照
src/main.ts,找出五种收窄手段各出现在哪一行:typeof、in、instanceof、!== undefined与判别字段、type guard。取消
longest(10, 20)那行的注释,观察编译器怎样拒绝不满足extends约束的调用,再改回去。做一个「破坏性实验」:把
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 接住——失败全部作为 Result 的 err 分支返回,没有一处裸抛异常。
如何判断成功:输出与预期一致(最后一行措辞可随 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.ts 的 Result / ok / err / toError(上一节的 SourceRef)。
本章小结
- 泛型把「类型」变成参数:
<T>声明、调用时由编译器推断填充,让通用函数和容器不丢类型;T extends 约束用「接受的类型变少」换「函数体内能做的事变多」。 - class 把字段和方法捆进一个模板,
new出数据独立的实例;private/readonly是编译期的访问纪律;一条 class 声明同时创建运行时的构造函数和编译期的实例类型,而 interface 只有后者。 - 类型收窄是「用运行时检查说服编译器」:五种证据——
typeof、in、instanceof、字面量判别(含判空)、自定义 type guard(x is T)。 unknown是安全版的any:先收窄、后使用,由编译器强制执行;处理流程是 typeof → 排除 null → instanceof / in / type guard → 兜底。- Pi 中
Result+ok/err是泛型与可辨识联合的合体,Agent、Session都是 class,toError是 unknown 规整的范本。
关键术语:泛型(Generic)、类型参数、约束(extends)、class、构造函数(constructor)、实例、private / readonly、类型收窄(Narrowing)、类型守卫(Type Guard)、unknown、类型断言(as)
关键源码索引:packages/agent/src/harness/types.ts 的 Result / ok / err / toError;packages/agent/src/agent.ts 的 Agent;packages/agent/src/harness/session/session.ts 的 Session
自测问题:
function firstItem<T>(items: T[]): T | undefined与参数类型写成any[]的版本,在「调用方误用返回值」这件事上表现有什么不同?private字段在编译成 JavaScript 之后还受保护吗?为什么说它和readonly防的是「写错代码」而不是「恶意访问」?- 为什么
instanceof右边只能放 class 而不能放 interface?想在运行时判断一个值是否符合某个 interface 的形状,该用什么? catch (e)里的e在 strict 模式下是什么类型?写出把它安全变成Error的三步(提示:Pi 的toError)。
下一章预告:2.5 异步编程:Promise 与 async/await——类型的地基到此打完,接下来面对 JavaScript 的另一半灵魂:异步。Agent 的一切——调用模型、读写文件、等待工具结果——都是异步操作,Promise 是理解后续所有章节的钥匙。