Skip to content

2.2 interface、type 与函数类型

本章解决什么问题:上一章给单个值标了类型;但真实程序里传来传去的多半是「长着好几个字段的对象」和「函数」。本章解决三件事:怎么给对象的形状起名字(interface)、怎么给任意类型起别名(type)、怎么描述一个函数(参数、返回值、可选与默认值)。顺带讲清 TypeScript 与 Java / C++ 最不一样的地方——它看形状,不看名字。

前置知识2.1 类型入门:基本类型与对象;能用 tsx 运行 .ts 文件(1.2)。

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

  • interface 描述一个对象必须长什么样,并用 ? 标记可选字段;
  • 给函数写出参数类型、返回值类型,会用可选参数与默认值;
  • 说清 interfacetype 各自擅长什么,以及本书(和 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 相同):

ts
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'.
🌱 初学者提示interface 和类型标注一样,运行时不存在
1.1 讲过:类型在编译时被抹掉。interface 也一样——它编译后不产生任何 JavaScript 代码,纯粹是给编译器和编辑器看的「图纸」。所以不能在运行中问「这个对象是不是 Tool」;运行时如何校验数据,要靠 [2.4](/foundations/generics-classes-narrowing) 的类型收窄技巧和 Pi 在工具系统里用的运行时校验库([6.4](/pi-modules/tools))。

函数类型:参数、返回值、可选与默认值

对象形状解决了「数据长什么样」,函数类型解决「行为长什么样」。给上面的 Tool 配一个格式化函数:

ts
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 模式下无法通过编译。

函数本身也可以被描述成一种类型,写法是「参数列表 => 返回值」:

ts
(tool: Tool) => boolean

这叫函数类型表达式,读作「接收一个 Tool、返回布尔值的函数」。它马上就会派上用场。

type:给任何类型起别名

它是什么type任意类型起名字,不限于对象形状:

ts
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; ... } 也完全可行),差别在能力边界:

interfacetype
描述对象形状
extends 继承、被声明合并扩展
给函数类型、联合类型、基本类型起名

「声明合并」指同名 interface 的多次声明会自动合并成一个——Pi 的扩展机制靠它让应用方往 CustomAgentMessages 里追加自己的消息类型,第七部分会见到实例。

本书约定(与 Pi 源码风格一致):能用 interface 就用 interface;需要组合、联合或给函数类型命名时用 type。从源码结构看,Pi 正是这么写的:对象形状一律 interface(本章末尾的 AgentToolAgentContext 等),而「二选一」的联合和函数签名一律 type(如 ToolExecutionModeStreamFn,见下文 SourceRef)。你不必纠结教条,跟着这个约定读 Pi 源码不会遇到例外的惊吓。

extends:在旧形状上叠加新要求

它是什么extends 让一个 interface 继承另一个的全部字段,再追加自己的:

ts
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 结果:你好,interface

execute(input: string): string方法签名:方法名、参数、返回值一行写清。它还有一种等价拼写 execute: (input: string) => string——把方法当作「函数类型的字段」。在本书的使用范围内两者可以当作同一回事,Pi 源码里两种写法都有。

为什么需要 extends:没有它,ExecutableTool 就得把 Tool 的字段全部重抄一遍,两份定义从此各自漂移。有了它,「所有工具的共性」只在 Tool 里维护一份。一个 interface 也可以同时继承多个(interface C extends A, B),Pi 中不少配置接口就是这样拼起来的。

结构化类型:看形状,不看名字

现在回到开头的观念转换。下面这个对象从头到尾没提过 Tool,还多带了一个 Tool 里不存在的字段:

ts
const anonymousObject = {
  name: "grep",
  description: "在文件中搜索文本",
  version: 3,
};

console.log(describeTool(anonymousObject, true));

它能编译、能运行,真实输出:

grep:在文件中搜索文本(无标签)
📘 概念结构化类型(Structural Typing)
TypeScript 判断「值 X 能不能当类型 T 用」时,只核对 X 是否具备 T 要求的全部成员且类型兼容——即只看结构(形状),不看 X 声明时用了哪个类型名。这套规则俗称「鸭子类型」(Duck Typing):走起来像鸭子、叫起来像鸭子,就是鸭子。多出来的成员不妨碍兼容。
⚠️ 常见误解用 Java / C++ 的「名义类型」直觉理解 TypeScript
Java / C++ 是名义类型(Nominal Typing):一个类必须显式写 `implements Tool` / 继承自 `Tool`,才能当 `Tool` 用——认的是「名字与血缘」。带着这个直觉看 TypeScript 会产生两个典型误判:一是以为普通对象必须先「声明实现了某接口」才能传给要求该接口的函数(不需要,形状对就行,上面的 `anonymousObject` 就是证据);二是以为两个同名 interface 才互相兼容(错,两个名字毫无关系的 interface 只要形状相同就可互换)。反过来的推论更重要:**interface 约束不了「来源」**——任何人都能凑出一个形状相同的对象冒充,需要防伪时得靠运行时校验,而不是类型。
🌱 初学者提示那为什么直接写字面量时多余字段会报错?
把 `version: 3` 挪进直接传给函数的对象字面量(「字面量」指现场写出的 `{ ... }`)里,反而会报错——这是 tsc 的真实输出:
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 包,描述「模型眼中的工具」:

earendil-works/pi@c13ffe1第 480–485 行在 GitHub 查看 ↗
基础 Tool 接口:必填的 name、description、parameters,外加一个可选字段 constrainedSampling——与本章 Tool 例子如出一辙的「必填 + 可选」组合。

再看 AgentTool 本身(摘录关键行,注释有省略):

ts
/** 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;
}
earendil-works/pi@c13ffe1第 379–403 行在 GitHub 查看 ↗
AgentTool:本章全部语法在 25 行里同时登场——extends 继承、必填字段、可选的函数类型字段、方法签名、可选参数。

逐个成员看(尖括号里的 <TParameters ...> 是泛型——类型的「参数」,2.4 才讲,此处只需知道它是占位的类型即可):

  • extends Tool<TParameters>:继承上面那份底座,所以每个 AgentTool 自动要求 namedescriptionparameters。Pi 把「模型需要知道的」放在基座 Tool 里,把「运行时才需要的」叠加在 AgentTool 里——正是 extends 的教科书用法。
  • label: string:必填字段,界面上显示的人类可读名称。
  • prepareArguments?: (args: unknown) => ...可选的函数类型字段。工具可以选择提供一个「参数整形」函数,在校验前修正模型发来的原始参数;不提供就跳过这一步。「可选字段 + 函数类型」两个知识点在同一行会师。
  • execute: (...) => Promise<...>:方法签名,工具的执行入口。它的参数表里又嵌着本章语法:signal?onUpdate?可选参数——调用方可以不传取消信号、不订阅进度更新。返回值的 Promise 表示异步结果(2.5),AbortSignal 是取消机制(2.7),现在不必深究。
  • executionMode?: ToolExecutionMode:可选字段,类型是一个 type 别名。

顺着最后一个字段找到 ToolExecutionMode 的定义,就能看到「联合用 type」的约定:

packages/agent/src/types.ts · ToolExecutionMode
earendil-works/pi@c13ffe1第 42 行在 GitHub 查看 ↗
ToolExecutionMode 是 "sequential" 与 "parallel" 二选一的联合类型——interface 表达不了「二选一」,所以这里必须用 type。

同一文件开头还有用 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(实验索引见实践任务索引)。

步骤

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

    sh
    cd labs/typescript-basics/05-interfaces
    npm install
    npm start
  2. 对照输出与 expected-output.txt 是否完全一致。

  3. Tool 接口加一个必填字段 source: string;,然后运行类型检查:

    sh
    npx tsc --noEmit
  4. 写一个 SearchableTool 接口:extends Tool,追加方法签名 matches(keyword: string): boolean;实现一个对象,用 console.log 验证 matches 的行为。

  5. 撤销第 3 步的改动,确认 npm start 输出恢复原样、npx tsc --noEmit 无报错。

预期现象:第 2 步输出六行,最后一行是 grep:在文件中搜索文本(无标签)。第 3 步应看到 4 处报错:三处 TS2741(readToolbashTool,以及通过 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 startexpected-output.txt 逐字符一致,且 npx tsc --noEmit 无输出(无输出即通过)。

常见错误

  • npm start 正常但你以为类型也没问题——tsx 不做类型检查(1.2 讲过),必须靠 npx tsc --noEmit
  • 第 4 步把方法写成 matches: boolean——那是「布尔字段」不是方法,签名要写成 matches(keyword: string): boolean
  • 实现 SearchableTool 时漏掉 namedescription——extends 意味着基座字段一个都不能少。

对应源码位置packages/agent/src/types.tsAgentTool(本章 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.tsToolpackages/agent/src/types.tsAgentToolToolExecutionModeStreamFn

自测问题

  1. interfacetype 的能力边界差在哪里?按本书约定,给「"sequential" 或 "parallel" 二选一」起名字该用哪个,为什么?
  2. 字段的 tags?: string[] 和参数的 verbose: boolean = false 各解决什么问题?两者在「调用方可以不提供」这一点上相同,差别在哪?
  3. 一个从未声明过 implements Tool 的对象为什么能传给要求 Tool 的函数?什么情况下多出来的字段反而会报错?
  4. 只看本章摘录:AgentTool 里哪些成员是可选的?它通过 extends 从 pi-ai 的 Tool 继承了哪些必填字段?

下一章预告2.3 union、字面量与可辨识联合——本章两次擦肩而过的 |(「二选一」)正式登场:字面量类型、联合类型,以及 Pi 事件系统赖以运转的「可辨识联合」。

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