TS·05 泛型:参数化的类型、约束、与工具类型
到目前为止,我们写的类型都是"具体"的:string、number、某个 interface。但真实代码里,大量结构对"装什么"是无所谓的——一个数组容器不关心元素是数字还是字符串,一个"工具"抽象不关心它的参数 schema 长什么样。这就是泛型(generics):让类型本身成为参数,在使用时才填入具体类型。
如果你写过 Python 的 list[int]、dict[str, User],或者用过 typing.TypeVar / Generic[T],那你已经见过泛型的思想了。泛型解决的是同一个矛盾:逻辑要复用,类型不能丢。不用泛型,你要么给每种元素类型复制一份代码,要么退回 any 关掉检查——前者啰嗦、后者危险。泛型让"装什么"成为一个可推断、可约束、可给默认值的参数,一份代码守住所有类型。TS 还把这套思想推得更远:不仅函数和类能泛型化,连"从一个类型算出另一个类型"都能做——这就是本篇最后要讲的工具类型(utility types),是 TS 类型系统相对 Python 的一件独门武器。读完这篇,你就能读懂 pi 里最核心的几个抽象:统一所有 provider 的 Tool<TParameters>、驱动流式响应的 EventStream<T, R>,以及 agent 状态管理里那些 Partial<Omit<...>> 的类型手术。本篇是值与函数、interface 与 type之后的第五站。
1. 泛型函数与泛型接口:类型当参数
1.1 直觉
假设你要写一个"取数组第一个元素"的函数。如果写死 function first(xs: number[]): number,那它只能用于数字数组;换成字符串就得再写一个。你也许想用"逃生舱" any——function first(xs: any[]): any——但那等于关掉了类型检查:返回值成了 any,后续拿它做什么都不报错,类型联系彻底断了。泛型是"既复用逻辑、又不丢类型"的正解:在函数名后面加一对尖括号 <T>,声明一个类型参数 T——它是一个"占位类型",在调用时由 TS 根据实参自动推断填入。这样一个 first<T> 就同时服务于所有元素类型,且保持类型联系:传数字数组,返回值就是 number | undefined;传字符串数组,返回值就是 string | undefined。类型信息从入参一路流到出参,而不是在 any 那里断掉。
Python 对照:
<T>相当于TypeVar("T"),泛型函数/类相当于继承Generic[T]。后面会讲的约束T extends X,则对应TypeVar("T", bound=X)。区别是 TS 的类型参数在编译期就完成推断和检查,运行时完全不存在。
1.2 最小 demo
// 教学示例 — 非生产代码
// 泛型函数:T 由实参推断
function first<T>(xs: T[]): T | undefined {
return xs[0];
}
const n: number | undefined = first([1, 2, 3]); // T 推断为 number
const s: string | undefined = first(["a", "b"]); // T 推断为 string
// 泛型接口:容器不关心装什么
interface Box<T> {
value: T;
}
const b: Box<string> = { value: "hi" };
// 约束:T 必须至少有 length 字段
function len<T extends { length: number }>(x: T): number {
return x.length;
}
len("hello"); // ok,string 有 length
len([1, 2, 3]); // ok,数组有 length
// len(42); // ✗ number 没有 length,编译报错
// 默认类型参数:不传就用 string
interface Wrapper<T = string> {
data: T;
}
const w: Wrapper = { data: "default is string" }; // 等价于 Wrapper<string>
1.3 正式化
- 声明:在函数名或接口/类型名后加
<T>。名字随意,惯例单个用T,多个用T, U或语义化的TParameters、TDetails(pi 的风格)。 - 推断优先:调用泛型函数时通常不用手写类型实参,TS 从参数推断。
first([1,2,3])里T自动是number。需要时也可显式写first<number>([1,2,3])。 - 约束
T extends U:限制类型参数的上界——T必须能赋给U。约束后,函数体内就能安全访问U上的属性(如上例的.length)。注意这里的extends不是"继承",而是"满足…的形状"。 - 默认
T = X:调用方未提供、且无法推断出类型实参时,回退到默认类型。可与约束同写:<T extends U = U>——既限定上界,又给缺省值,这正是 pi 里最常见的一行写法。默认类型本身必须满足约束(X得能赋给U),否则报错。 - 多个类型参数与索引取元素类型:一个签名可声明多个类型参数,如
Map<K, V>。约束里还能引用其它类型参数,写出参数之间的依赖,例如function pluck<T, K extends keyof T>(obj: T, key: K): T[K]——keyof T是"T所有键名组成的联合",T[K]是"用键K索引到的属性类型"。这套keyof/T[K]的写法在下一节和第 3 节还会反复出现。
1.4 代码引用
pi 的 ai 包要把 ~30 家 LLM provider 统一成一套接口。每家的"工具调用(tool call)"参数 schema 各不相同,于是"工具"这个抽象天生就是泛型的:
pi/packages/ai/src/types.ts:L440-L444 — Tool 接口:一行同时写约束 + 默认值
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
再看一个泛型函数,带约束但用途完全不同——它把一个字符串数组变成运行时 schema:
pi/packages/ai/src/utils/typebox-helpers.ts:L14-L24 — StringEnum:约束 T 为只读字符串数组
export function StringEnum<T extends readonly string[]>(
values: T,
options?: { description?: string; default?: T[number] },
): TUnsafe<T[number]> {
return Type.Unsafe<T[number]>({
type: "string",
enum: values as any,
...(options?.description && { description: options.description }),
...(options?.default && { default: options.default }),
});
}
对照:Tool<TParameters extends TSchema = TSchema> 就是 1.3 讲的"约束 + 默认"一行写法——TParameters 必须是一个 typebox schema(TSchema 是上界),而调用方懒得指定时就退回 TSchema 本身。parameters: TParameters 让每个具体工具都携带自己精确的参数类型,而不是笼统的 TSchema。StringEnum 则是泛型函数:约束 T extends readonly string[] 保证传入的是字符串数组,而返回类型里的 T[number](数组的"元素类型")让产出的 schema 精确到 "add" | "subtract" | ... 这种字面量联合——类型信息从入参一路流到了出参。
1.5 洞察
- 约束
T extends U里的extends读作"满足 U 的形状/是 U 的子类型",与类继承的extends是同一个关键字、不同语境;别被它误导成"必须是某个类的子类"。例如T extends { length: number }接受一切有length的类型——string、数组、甚至自定义对象——这是结构化(鸭子)子类型,不看名字只看形状。 - 能让 TS 推断就别手写类型实参——显式
f<number>(...)通常是坏味道,说明你在和推断器较劲。真正需要显式的场景很少(如空数组无从推断)。 - 约束 + 默认(
<T extends U = U>)是库作者的惯用签名:对外提供"填得越具体、类型越精确"的能力,同时保留"什么都不填也能用"的便利。pi 全仓都是这个套路。 - 泛型和
any的区别是全篇最该记住的一点:any[]只是"放弃检查",拿出的元素类型信息全丢;T[]是"把类型作为参数记住",元素类型一路守到出口。见到别人用any做容器/透传,先想想能不能换成一个类型参数。
2. 泛型类,以及泛型继承泛型
2.1 直觉
类也能泛型化:class C<T> 让同一份类逻辑服务于任意元素类型,内部字段、构造函数和方法都能引用 T,实例化时把 T 固定下来(如 new C<number>()),此后这个实例就"专营"数字。类型参数可以有多个,且各自带默认值:class C<T, R = T> 表示"两个类型参数,第二个不传就等于第一个"——当两个概念通常相同、偶尔不同时,这个默认能省掉绝大多数调用点的第二个实参。而当一个泛型接口 extends 另一个泛型接口时,子接口要给父接口填入类型实参——这就是"泛型继承泛型",让抽象可以分层:底层接口定义通用形状,上层接口在同一个类型参数上追加更具体的字段。这一节的两个 pi 例子——异步事件流、agent 工具——正好覆盖这两种形态。
Python 对照:
class EventStream(Generic[T, R])是等价写法,但 Python 的TypeVar不能像 TS 那样直接给"第二个参数默认等于第一个";TS 的<T, R = T>把这种依赖关系写进了签名。
2.2 最小 demo
// 教学示例 — 非生产代码
// 双类型参数的泛型类,R 默认等于 T
class Channel<T, R = T> {
private buffer: T[] = [];
constructor(private finalize: (last: T) => R) {}
push(x: T): void {
this.buffer.push(x);
}
// 用 finalize 把最后一个 T 变成结果 R
result(): R {
return this.finalize(this.buffer[this.buffer.length - 1]);
}
}
// 不传 R:R = T = number,result() 返回 number
const c1 = new Channel<number>((last) => last);
c1.push(1); c1.push(2);
const r1: number = c1.result();
// 传 R:T=number,R=string,两者不同
const c2 = new Channel<number, string>((last) => `last=${last}`);
c2.push(9);
const r2: string = c2.result();
// 泛型接口继承泛型接口:子接口给父接口填实参
interface Sized<T> {
size: number;
sample: T;
}
interface Labeled<T> extends Sized<T> {
label: string; // 在父接口 Sized<T> 基础上加字段
}
const box: Labeled<string> = { size: 3, sample: "x", label: "words" };
2.3 正式化
- 泛型类:
class C<T> { field: T; method(x: T): T {...} }。类型参数在整个类体内可见,包括字段、构造函数、方法。实例化时new C<number>(...),或让 TS 从构造参数推断。 - 多参数与默认:
<T, R = T>。默认值只对末尾连续的参数有意义(和函数默认参数一样)——<T = string, R>不合法,因为若省略T就没法给R定位。 implements:类可声明它实现某个接口,class C<T> implements SomeInterface<T>,编译器据此检查形状。这与继承类的extends不同——implements只做形状约束,不带来任何实现。- 接口继承接口:
interface Child<T> extends Parent<T>。子接口必须为父接口的每个类型参数提供实参(可以是自己的类型参数T,也可以是具体类型Parent<string>)。子接口继承父接口全部成员,并可新增。可一次extends多个父接口(extends A<T>, B逗号分隔),这是接口相对类的一个便利——类只能extends单个父类。 - 推断也适用于类:
new Channel((last) => last)时,若构造参数足以定位类型参数,可省略显式<...>;但当类型参数不出现在任何构造参数里(如上例R只出现在finalize的返回)、或数组为空等无从推断时,就得显式写new Channel<number, string>(...)。
2.4 代码引用
pi 用一个泛型类把"流式事件的异步迭代"封装起来。注意它有两个类型参数,第二个带默认:
pi/packages/ai/src/utils/event-stream.ts:L4-L19 — EventStream:双类型参数 + implements 泛型接口
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
再看"泛型接口继承泛型接口":agent 层的 AgentTool 在第一节的 Tool 基础上扩展:
pi/packages/agent/src/types.ts:L373-L387 — AgentTool 继承 Tool 并新增字段,用 Static<TParameters> 派生参数类型
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => Static<TParameters>;
/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
}
对照:EventStream<T, R = T> 正是 2.3 的"多参数 + 末尾默认"——流里逐个吐出的事件类型是 T,而整条流跑完提取出的最终结果类型是 R;很多流的"最终结果"就是"最后一个事件",所以 R = T 是合理缺省。implements AsyncIterable<T> 是"实现泛型接口",承诺这个类可被 for await...of 迭代(见异步与流式)。而 AgentTool<TParameters, TDetails> extends Tool<TParameters> 是"泛型接口继承泛型接口":它把自己的 TParameters 透传给父接口 Tool<TParameters>,继承 name/description/parameters,再加上 label、execute 等 agent 专属字段。execute 的参数类型写成 Static<TParameters>——这是 pi 的核心手法:把运行时 typebox schema TParameters 通过 Static<...> 反算出对应的静态 TS 类型,于是 schema 一处定义,静态类型自动同步。
2.5 洞察
- 默认类型参数必须放在参数列表末尾,和函数默认参数同理;
<T, R = T>合法,<T = A, R>不合法。 implements Interface<T>只检查"类的形状是否符合接口",不注入任何代码;真正复用实现要靠类继承(extends,下一篇类与模块细讲)。Static<TParameters>体现了 pi 的"schema 为源、类型为派生"哲学:因为开了erasableSyntaxOnly(全仓无 enum/decorator),运行时校验交给 typebox,静态类型用Static<typeof schema>从同一个 schema 导出,杜绝"类型和校验逻辑各写一遍、然后不一致"的经典 bug。这与 Python 里 Pydantic 模型"一处定义、既做校验又是类型"是同一思路,只是 TS 把"派生类型"这一步显式地交给了Static<...>这个泛型。- 泛型类里的
private resolveFinalResult!: (result: R) => void用了!(明确赋值断言),告诉编译器"这个字段虽然没在构造函数体的字面顺序里初始化,但我保证它会被赋值";这是泛型之外的一个小语法点,遇到别慌。 T与R之间可以毫无关系,也可以像EventStream那样通过构造参数extractResult: (event: T) => R建立"从 T 得到 R"的联系;类型参数的价值就在于把这种关系写进签名,让调用方一眼看清"喂进去什么、拿出来什么"。
3. 工具类型:Partial / Pick / Omit / Record / Extract / ReturnType
3.1 直觉
前两节是"用类型参数造泛型的东西"。这一节反过来:TS 内置了一批工具类型(utility types),它们像"作用在类型上的函数"——输入一个已有类型,输出一个新类型。你想"把某接口所有字段变可选""只挑其中几个字段""从联合里筛出某一支""拿到某函数的返回类型"——都不用手抄一遍原类型,一个工具类型搞定,而且原类型改了,派生类型自动跟着变。它们本质上就是官方预定义好的泛型别名(type Partial<T> = { [K in keyof T]?: T[K] } 之类),用到了 keyof、索引访问、映射与条件类型这些"类型层的运算符";你现在不必会自己写,但要能读懂并使用它们,因为 pi 的类型定义里到处是这种"从旧类型算新类型"。
Python 对照:这类"从类型算出类型"的能力,Python 的类型系统基本没有。
typing里有Optional、Union,3.11+ 也补了Required/NotRequired(限于TypedDict),但没有Pick/Omit/ReturnType这种在任意类型上做映射/筛选/反射的通用运算符。原因在于 Python 类型只是给静态检查器(mypy/pyright)看的注解、运行时几乎不参与;而 TS 编译器把类型本身做成了一门可求值的小语言。这是 TS 类型系统的独门武器,也是读 pi 代码绕不开的一关——不懂工具类型,很多type X = ...<...>定义就会卡住。
3.2 最小 demo
// 教学示例 — 非生产代码
interface User {
id: string;
name: string;
email: string;
age: number;
}
// Partial<T>:所有字段变可选(常用于"部分更新")
type UserPatch = Partial<User>;
const patch: UserPatch = { name: "new name" }; // 只给一个字段也 ok
// Pick<T, K>:只保留指定字段
type UserPublic = Pick<User, "id" | "name">;
const pub: UserPublic = { id: "u1", name: "Ann" };
// Omit<T, K>:去掉指定字段(Pick 的反面)
type UserNoEmail = Omit<User, "email">; // { id; name; age }
// Record<K, V>:构造键值映射类型
type Scores = Record<string, number>;
const s: Scores = { math: 90, cs: 100 };
// Extract<U, M>:从联合 U 里筛出可赋给 M 的那些支
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
type Circle = Extract<Shape, { kind: "circle" }>; // 只剩 circle 那支
// ReturnType<F>:反射出函数的返回类型
function makeUser() {
return { id: "x", name: "y" };
}
type MadeUser = ReturnType<typeof makeUser>; // { id: string; name: string }
3.3 正式化
这些工具类型都接受一个或多个类型实参(注意是类型,不是值):
Partial<T>:把T的每个属性变可选(加?)。反义词Required<T>去掉所有?。Pick<T, K>:K是T的属性名(键)组成的字符串联合;结果只保留这些键。Omit<T, K>:与Pick相反,从T里删掉K指定的键,其余保留。K不必是T已有的键也不报错。Record<K, V>:构造一个"键类型为K、值类型为V"的对象类型,如Record<string, number>。相当于 Python 的dict[K, V]在类型层的写法。Extract<U, M>:从联合类型U中,保留能赋给M的成员;反义Exclude<U, M>保留不能赋给M的。常配合可辨识联合按kind/type标签筛支。ReturnType<F>:取函数类型F的返回类型。配套的Parameters<F>取参数元组。注意F要是类型,所以常写ReturnType<typeof fn>——typeof fn把值fn变成它的函数类型。
工具类型可嵌套组合,从内往外读:Partial<Omit<T, K>> = 先 Omit<T, K> 删掉 K、再 Partial<...> 把剩下的全变可选。它们还常和索引访问类型搭配:T["prop"] 取属性 prop 的类型,T["items"][number] 取数组元素类型——第 4 节的 AssistantMessage["content"][number] 就是这个组合。这些写法叠起来看着吓人,但每一步都是"输入类型 → 输出类型"的纯函数,拆开逐层读就不难。
3.4 代码引用
先看 Extract 从可辨识联合里精确挑一支。AssistantMessage["content"] 是一个内容块数组,[number] 取其元素类型(是个联合),再用 Extract 筛出 type: "toolCall" 那一支:
pi/packages/agent/src/types.ts:L51-L52 — Extract:从内容块联合里挑出 toolCall 那支
/** A single tool call content block emitted by an assistant message. */
export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
再看 Omit 与 Partial<Omit<...>> 的组合。agent 的完整状态 AgentState 里有几个"运行时才有、不该由外部初始化"的字段;MutableAgentState 用 Omit 去掉它们再重新定义,而对外的初始状态参数用 Partial<Omit<...>>——既去掉那几个字段,又让剩下的全可选:
pi/packages/agent/src/agent.ts:L60-L68 — Omit 去字段、Partial<Omit<...>> 组合
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
isStreaming: boolean;
streamingMessage?: AgentMessage;
pendingToolCalls: Set<string>;
errorMessage?: string;
};
function createMutableAgentState(
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>,
): MutableAgentState {
最后是 ReturnType<typeof ...>,给一个第三方库函数的返回值起类型别名——ignore 是导入的库函数,ignore() 返回一个匹配器对象,pi 不想手抄它的类型,直接反射:
pi/packages/agent/src/harness/skills.ts:L9 — ReturnType<typeof ignore>:反射库函数返回类型
type IgnoreMatcher = ReturnType<typeof ignore>;
对照:Extract<AssistantMessage["content"][number], { type: "toolCall" }> 一行做了三件事——["content"] 是索引访问类型(取属性的类型)、[number] 取数组元素类型、Extract<..., { type: "toolCall" }> 从这个内容块联合里筛出工具调用那支;结果 AgentToolCall 会随 AssistantMessage 定义变化自动更新。Omit<AgentState, "isStreaming" | ...> 把四个运行时字段摘掉,& 再拼回可变版本(这是交叉类型),是典型的"以现有类型为底做手术";Partial<Omit<AgentState, ...>> 则叠加一层"全部可选",精确表达"初始化时这些字段都可以不给"。ReturnType<typeof ignore> 把值 ignore 先经 typeof 变成函数类型,再取其返回类型——一处引用,库升级后类型自动跟随,不必手工同步。
3.5 洞察
typeof在类型位置是"取某个值的类型",和运行时的typeof x === "string"是两码事;ReturnType<typeof fn>/Pick<typeof config, ...>里的typeof都是前者。Omit的第二个参数(要删的键)不校验是否真实存在于源类型,写错键名不报错、静默无效;Pick的键则必须存在。改字段名后记得回头核对Omit。- 工具类型让"派生类型"始终追随"源类型"——源接口加一个字段,
Partial<T>/Omit<T, K>自动包含它。这正是不该手抄类型、而该用工具类型的根本理由:单一事实来源。Python 里想要这种效果通常只能靠运行时的 dataclass 反射,拿不到编译期的静态保证。 - 工具类型可以自由组合、嵌套,但别为炫技把五六层套在一起——超过两三层就该考虑抽一个具名
type别名(像 pi 把Omit<...>的结果命名为MutableAgentState),既可读又可复用。 - 这些工具类型只是 TS 内置的一小撮常用款;它们背后的
keyof、映射类型{ [K in ...]: ... }、条件类型T extends U ? X : Y才是"类型编程"的完整语法。日常读 pi 用不到自己写这些,认得工具类型的名字与含义足矣。
讨论 / Comments
评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。