2.2 interface、type 与函数类型
本章解决什么问题:上一章给单个值标了类型;但真实程序里传来传去的多半是「长着好几个字段的对象」和「函数」。本章解决三件事:怎么给对象的形状起名字(interface)、怎么给任意类型起别名(type)、怎么描述一个函数(参数、返回值、可选与默认值)。顺带讲清 TypeScript 与 Java / C++ 最不一样的地方——它看形状,不看名字。
前置知识:2.1 类型入门:基本类型与对象;能用
tsx运行.ts文件(1.2)。学习目标:读完本章后你能
- 用
interface描述一个对象必须长什么样,并用?标记可选字段;- 给函数写出参数类型、返回值类型,会用可选参数与默认值;
- 说清
interface和type各自擅长什么,以及本书(和 Pi 源码)的选择约定;- 解释「结构化类型」:为什么一个从没提过
Tool名字的对象能当Tool用;- 读懂 Pi 源码里
AgentTool这样「extends + 方法 + 可选字段」俱全的接口定义。
建立直觉
把 interface 想成一份岗位要求:「本岗位要求会读文件、会写代码」。招聘方核对的是你能做什么,而不是你毕业证上印的校名。TypeScript 检查对象是否符合某个 interface 时用的正是这个逻辑:逐条核对字段齐不齐、类型对不对;至于这个对象出生时叫什么名字、有没有声明过「我是 Tool」,一概不问。
这和 Java / C++ 的习惯正好相反——那边一个类必须显式写 implements Tool 才算 Tool。这个差异是本章最重要的一个观念转换,下文有专门小节。
先看全景:本章所有语法最后都会在 Pi 的一个真实接口 AgentTool 里同时出现,所以例子也统一用「工具」当主角——工具(Tool)是什么、Agent 为什么需要工具,3.4 工具调用才正式讲,这里只把它当成「有名字、有描述的一种对象」即可。
interface:给对象形状起名字
它是什么:interface 声明一种对象形状——列出字段名和每个字段的类型,然后给这个形状起个名字。
为什么需要它:2.1 里你已经见过直接在变量上写对象类型。但同一个形状往往在几十个函数间传递,如果每处都把 { name: string; description: string } 重抄一遍,一旦形状要改(比如加一个字段),就得挨个改所有抄写处,漏一处就埋一个雷。
没有它会怎样:不是写不出程序,而是「形状」这个知识散落在各处、无法统一修改,类型检查的最大红利——改一处定义、所有不符处全部现形——就拿不到了。
最小例子(与本章配套实验 labs/typescript-basics/05-interfaces 相同):
interface Tool {
name: string;
description: string;
/** 可选字段:不是每个工具都有标签 */
tags?: string[];
}
const readTool: Tool = {
name: "read-file",
description: "读取一个文件的内容",
tags: ["文件", "只读"],
};
const bashTool: Tool = {
name: "bash",
description: "执行一条 shell 命令",
// 没写 tags:可选字段允许缺席
};两处语法值得停下来看:
- 字段列表:
name: string;读作「必须有一个叫 name 的字段,类型是字符串」。 - 可选字段
?:tags?: string[]表示「可以有,也可以没有」。readTool写了,bashTool没写,都合法。代价是:使用tags前必须先排除它不存在的情况(strict 模式下编译器会强制你这么做),下一节的例子会演示。
违反形状会立刻被抓。把 description 删掉试试,tsc 的真实报错是:
demo1.ts(7,7): error TS2741: Property 'description' is missing in type '{ name: string; }' but required in type 'Tool'.函数类型:参数、返回值、可选与默认值
对象形状解决了「数据长什么样」,函数类型解决「行为长什么样」。给上面的 Tool 配一个格式化函数:
function describeTool(tool: Tool, verbose: boolean = false): string {
const tagText = tool.tags === undefined ? "无标签" : tool.tags.join("、");
if (verbose) {
return `${tool.name}:${tool.description}(${tagText})`;
}
return tool.name;
}
console.log(describeTool(readTool)); // 省略 verbose,走默认值
console.log(describeTool(readTool, true));真实输出(来自配套实验):
read-file
read-file:读取一个文件的内容(文件、只读)逐个拆解:
- 参数类型
tool: Tool:接口名在这里当类型用,任何符合Tool形状的值都能传进来。 - 返回值类型
: string:写在参数列表之后。就算不写,TypeScript 也能推断出来,但对外暴露的函数建议写明——它是给读者看的契约。 - 默认值
verbose: boolean = false:调用方可以不传,不传就用false。有默认值的参数自动成为可选参数。 - 可选参数:不需要默认值时也可以只写
verbose?: boolean——和字段的?一个意思,此时函数体内verbose的类型是boolean | undefined(|表示「二者之一」,下一章展开)。 - 可选字段的代价兑现了:
tool.tags可能不存在,所以先用tool.tags === undefined检查。删掉这个检查直接tool.tags.join(...),strict 模式下无法通过编译。
函数本身也可以被描述成一种类型,写法是「参数列表 => 返回值」:
(tool: Tool) => boolean这叫函数类型表达式,读作「接收一个 Tool、返回布尔值的函数」。它马上就会派上用场。
type:给任何类型起别名
它是什么:type 给任意类型起名字,不限于对象形状:
type ToolFilter = (tool: Tool) => boolean;
const hasTags: ToolFilter = (tool) => tool.tags !== undefined;
const tagged = [readTool, bashTool].filter(hasTags);
console.log(`带标签的工具数量:${tagged.length}`); // 输出:带标签的工具数量:1注意 hasTags 的参数 tool 没写类型——因为变量声明成了 ToolFilter,TypeScript 从别名里反推出参数类型。给常用的函数形状起名,是 type 最常见的用途之一。
interface 与 type 怎么选:两者在「描述对象形状」这件事上高度重叠(type Tool = { name: string; ... } 也完全可行),差别在能力边界:
interface | type | |
|---|---|---|
| 描述对象形状 | ✅ | ✅ |
被 extends 继承、被声明合并扩展 | ✅ | ❌ |
| 给函数类型、联合类型、基本类型起名 | ❌ | ✅ |
「声明合并」指同名 interface 的多次声明会自动合并成一个——Pi 的扩展机制靠它让应用方往 CustomAgentMessages 里追加自己的消息类型,第七部分会见到实例。
本书约定(与 Pi 源码风格一致):能用 interface 就用 interface;需要组合、联合或给函数类型命名时用 type。从源码结构看,Pi 正是这么写的:对象形状一律 interface(本章末尾的 AgentTool、AgentContext 等),而「二选一」的联合和函数签名一律 type(如 ToolExecutionMode、StreamFn,见下文 SourceRef)。你不必纠结教条,跟着这个约定读 Pi 源码不会遇到例外的惊吓。
extends:在旧形状上叠加新要求
它是什么:extends 让一个 interface 继承另一个的全部字段,再追加自己的:
interface ExecutableTool extends Tool {
/** 方法签名:接收输入字符串,返回执行结果字符串 */
execute(input: string): string;
}
const echoTool: ExecutableTool = {
name: "echo", // 来自 Tool 的要求
description: "原样返回输入", // 来自 Tool 的要求
execute(input) { // ExecutableTool 追加的要求
return `echo 结果:${input}`;
},
};
console.log(echoTool.execute("你好,interface")); // 输出:echo 结果:你好,interfaceexecute(input: string): string 是方法签名:方法名、参数、返回值一行写清。它还有一种等价拼写 execute: (input: string) => string——把方法当作「函数类型的字段」。在本书的使用范围内两者可以当作同一回事,Pi 源码里两种写法都有。
为什么需要 extends:没有它,ExecutableTool 就得把 Tool 的字段全部重抄一遍,两份定义从此各自漂移。有了它,「所有工具的共性」只在 Tool 里维护一份。一个 interface 也可以同时继承多个(interface C extends A, B),Pi 中不少配置接口就是这样拼起来的。
结构化类型:看形状,不看名字
现在回到开头的观念转换。下面这个对象从头到尾没提过 Tool,还多带了一个 Tool 里不存在的字段:
const anonymousObject = {
name: "grep",
description: "在文件中搜索文本",
version: 3,
};
console.log(describeTool(anonymousObject, true));它能编译、能运行,真实输出:
grep:在文件中搜索文本(无标签)demo2.ts(14,3): error TS2353: Object literal may only specify known properties, and 'version' does not exist in type 'Tool'.这是结构化类型之上的一条特例,叫多余属性检查:对「新鲜出炉、直接用于该类型」的对象字面量,多出来的字段几乎可以肯定是拼写错误(比如把 tags 敲成了 tag),所以从严报错。先存进变量再传(如 anonymousObject),走的才是纯粹的形状判断。
图解
图 2.2-1 结构化类型的判定过程
阅读顺序:从上往下。这张图说明 TypeScript 如何判定「候选值能不能当 Tool 用」:主干是逐字段核对形状(左下是本章第一个报错例子 TS2741);右侧分叉是唯一的特例——直接写出的对象字面量要额外过一道多余属性检查(TS2353)。注意终点节点:全程没有任何一步在问「你声明过自己是 Tool 吗」。
Pi 中哪里用到了它
打开 packages/agent/src/types.ts,这个文件几乎就是本章语法的「实弹演习场」:四百多行里没有一行可执行逻辑,全部是 interface 与 type 声明。我们挑其中最有代表性的 AgentTool——Pi 的 Agent 运行时对「一个工具」的完整定义——逐字段对照本章内容。
先看它继承的底座。AgentTool extends 的 Tool 来自 pi-ai 包,描述「模型眼中的工具」:
Tool再看 AgentTool 本身(摘录关键行,注释有省略):
/** Tool definition used by the agent runtime. */
export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
// …(省略:prepareArguments 的注释)
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>>;
// …(省略:executionMode 的注释)
executionMode?: ToolExecutionMode;
}AgentTool逐个成员看(尖括号里的 <TParameters ...> 是泛型——类型的「参数」,2.4 才讲,此处只需知道它是占位的类型即可):
extends Tool<TParameters>:继承上面那份底座,所以每个AgentTool自动要求name、description、parameters。Pi 把「模型需要知道的」放在基座Tool里,把「运行时才需要的」叠加在AgentTool里——正是 extends 的教科书用法。label: string:必填字段,界面上显示的人类可读名称。prepareArguments?: (args: unknown) => ...:可选的函数类型字段。工具可以选择提供一个「参数整形」函数,在校验前修正模型发来的原始参数;不提供就跳过这一步。「可选字段 + 函数类型」两个知识点在同一行会师。execute: (...) => Promise<...>:方法签名,工具的执行入口。它的参数表里又嵌着本章语法:signal?和onUpdate?是可选参数——调用方可以不传取消信号、不订阅进度更新。返回值的Promise表示异步结果(2.5),AbortSignal是取消机制(2.7),现在不必深究。executionMode?: ToolExecutionMode:可选字段,类型是一个type别名。
顺着最后一个字段找到 ToolExecutionMode 的定义,就能看到「联合用 type」的约定:
ToolExecutionMode同一文件开头还有用 type 给函数类型起名的 StreamFn(Agent 循环所用的流式请求函数的形状)。一份 types.ts 内部,interface 与 type 的分工与本书约定完全一致。这些接口如何被 Agent 循环真正消费,是6.4 工具系统的主线。
图 2.2-2 Pi 中 AgentTool 对 Tool 的继承关系
阅读顺序:从上往下。上方是 pi-ai 包的基础 Tool(packages/ai/src/types.ts:480),只含模型可见的信息;下方 AgentTool(packages/agent/src/types.ts:380)通过 extends 继承全部字段,再叠加运行时才需要的 label、execute 等成员。带 ? 的成员均为可选。
实践任务
labs/typescript-basics/05-interfaces目标:跑通本章的完整示例,然后亲手制造并修复三类典型形状错误,体会「改一处定义、全部不符处现形」。实验目录:labs/typescript-basics/05-interfaces(实验索引见实践任务索引)。
步骤:
进入实验目录,安装并运行:
shcd labs/typescript-basics/05-interfaces npm install npm start对照输出与
expected-output.txt是否完全一致。给
Tool接口加一个必填字段source: string;,然后运行类型检查:shnpx tsc --noEmit写一个
SearchableTool接口:extends Tool,追加方法签名matches(keyword: string): boolean;实现一个对象,用console.log验证matches的行为。撤销第 3 步的改动,确认
npm start输出恢复原样、npx tsc --noEmit无报错。
预期现象:第 2 步输出六行,最后一行是 grep:在文件中搜索文本(无标签)。第 3 步应看到 4 处报错:三处 TS2741(readTool、bashTool,以及通过 extends 连坐的 echoTool),最后一处在 describeTool(anonymousObject, true) 调用点——形状检查同样拦下了这个「无名对象」。作者机器上第一条真实报错:
src/main.ts(24,7): error TS2741: Property 'source' is missing in type '{ name: string; description: string; tags: string[]; }' but required in type 'Tool'.如何判断成功:第 5 步后 npm start 与 expected-output.txt 逐字符一致,且 npx tsc --noEmit 无输出(无输出即通过)。
常见错误:
npm start正常但你以为类型也没问题——tsx不做类型检查(1.2 讲过),必须靠npx tsc --noEmit;- 第 4 步把方法写成
matches: boolean——那是「布尔字段」不是方法,签名要写成matches(keyword: string): boolean; - 实现
SearchableTool时漏掉name或description——extends 意味着基座字段一个都不能少。
对应源码位置:packages/agent/src/types.ts 的 AgentTool(本章 SourceRef)就是这套练习的「满配版」。
本章小结
interface给对象形状起名:字段名 + 类型,?标记可选字段;可选字段使用前必须先排除不存在的情况。- 函数类型由参数与返回值描述;参数可以可选(
?)或带默认值(=);(x: T) => U是函数类型表达式;interface 里可以写方法签名。 type能给任何类型起别名;本书与 Pi 的约定:对象形状用interface,联合与函数类型命名用type。extends让接口继承另一个接口的全部字段再叠加新要求,避免形状定义重复。- TypeScript 是结构化类型:兼容性只看形状不看名字;唯一特例是对新鲜对象字面量的多余属性检查。
- Pi 的
AgentTool在 25 行里集齐了本章全部语法:extends、必填字段、可选函数字段、方法签名、可选参数。
关键术语:interface、可选字段、函数类型表达式、默认参数、type 别名、方法签名、extends(接口继承)、结构化类型(Structural Typing)/鸭子类型、名义类型(Nominal Typing)、多余属性检查
关键源码索引:packages/ai/src/types.ts 的 Tool;packages/agent/src/types.ts 的 AgentTool、ToolExecutionMode、StreamFn
自测问题:
interface和type的能力边界差在哪里?按本书约定,给「"sequential" 或 "parallel" 二选一」起名字该用哪个,为什么?- 字段的
tags?: string[]和参数的verbose: boolean = false各解决什么问题?两者在「调用方可以不提供」这一点上相同,差别在哪? - 一个从未声明过
implements Tool的对象为什么能传给要求Tool的函数?什么情况下多出来的字段反而会报错? - 只看本章摘录:
AgentTool里哪些成员是可选的?它通过extends从 pi-ai 的Tool继承了哪些必填字段?
下一章预告:2.3 union、字面量与可辨识联合——本章两次擦肩而过的 |(「二选一」)正式登场:字面量类型、联合类型,以及 Pi 事件系统赖以运转的「可辨识联合」。