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 正式化

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 洞察

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 正式化

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 洞察

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<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 洞察

讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。