TS·02 数据的形状:interface 与 type
上一篇(值、类型、函数)我们给单个值标了类型:number、string、boolean。但真实程序里流动的极少是裸值,几乎全是对象——一条消息、一份配置、一次工具调用的结果。这一篇讲 TypeScript 怎么描述"一个对象长什么样":它有哪些字段、每个字段是什么类型、哪些可以省略、哪些不许改。两件工具是 interface 和 type。
pi 这个 AI coding-agent 框架从头到尾就是靠这些"形状定义"把 30 家 LLM provider、agent 主循环、文件系统契约黏在一起的。读完这篇,你就能读懂 pi 里那些 interface TextContent、type ProviderEnv = Record<...>、以及一屏幕带问号的 options 配置对象——它们是整个仓库最高频的语法。本篇只讲对象的形状;字段值取自一个封闭集合的"可辨识联合"(discriminated union)留到下一篇。
1. interface:描述一个对象长什么样
1.1 直觉
interface(接口)给一个对象的形状起个名字:它规定"凡是这种对象,必须有这几个字段,每个字段是什么类型"。之后你在任何地方写 x: TextContent,编译器就会检查 x 是否真的长这个样子——少字段、字段类型不对,都会在编译期报错,程序根本跑不起来。有了名字,这个形状就能被复用:函数参数、返回值、数组元素、另一个 interface 的字段,都可以直接引用它,而不必每次重复一遍花括号里那串字段。
Python 对照:
interface很像 Python 的TypedDict(描述 dict 的键与值类型)或Protocol(描述"有哪些属性/方法")。关键差异有两点:一是 interface 只存在于编译期,编译成 JS 后完全被擦除,运行时没有任何痕迹、也不做检查;二是 TS 编译期真的会逐字段核对,不像 Python 的类型注解默认只是提示。
1.2 最小 demo
// 教学示例 — 非生产代码
interface User {
id: number;
name: string;
}
// 一个符合 User 形状的对象
const u: User = { id: 1, name: "Ada" };
// 函数参数用 interface 约束形状
function greet(user: User): string {
return "hi, " + user.name;
}
greet(u); // ok
greet({ id: 2, name: "Bob" }); // ok,字面量对象也逐字段检查
// greet({ id: 3 }); // 编译错误:缺少字段 name
// greet({ id: 4, name: 5 }); // 编译错误:name 应为 string,给了 number
User 不是变量、也不是值,它是一个类型名。写 const u: User 就是承诺 u 长成 User 那样;编译器盯着这个承诺,任何不符都拦下。
1.3 正式化
- 语法:
interface 名字 { 字段: 类型; ... }。字段之间用;或换行分隔(逗号也行,惯例用分号)。 - 字段类型可以是上一篇的基础类型,也可以是别的 interface、数组
T[]、联合类型等。 - 字面量类型字段:
type: "text"里的"text"不是"任意 string",而是只能是字符串"text"这一个值的类型(叫字符串字面量类型)。它把这个 interface 打上了一个固定标签。 - interface 名字惯例用大驼峰(
PascalCase),export后可被其它文件import。 - interface 不产生任何运行时代码:
tsc编译后它整个消失,不会变成一个 class 或校验函数。
1.4 代码引用
pi 用一组小而干净的 interface 描述"助手消息里的一个内容块"。文本块和图片块各是一个 interface:
pi/packages/ai/src/types.ts:L323-L343 — 两个描述内容块形状的小 interface(中间 ThinkingContent 已略)
export interface TextContent {
type: "text";
text: string;
textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)
}
// ...
export interface ImageContent {
type: "image";
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/jpeg", "image/png"
}
对照:TextContent 规定了三个字段——type 必须是字面量 "text",text 是 string,textSignature 带问号(可选,下一章讲)。ImageContent 则用 data/mimeType 两个必填 string 描述一张图。注意两个 interface 都有一个 type 字段,值分别锁死为 "text" 和 "image"——这为 T3 的可辨识联合埋了伏笔:用 type 这个标签就能在运行时区分"手上这个内容块到底是文本还是图片"。
1.5 洞察
- interface 是编译期契约,运行时空气:别指望它在运行时替你校验从网络收到的 JSON。要运行时校验得另用工具(pi 用 typebox,后续篇章讲)。
type: "text"这种字面量字段不是废话——它是给对象贴的类型标签,后面章节的窄化(narrowing)全靠它。- 字面量对象赋值给 interface 时,TS 会做"多余属性检查":写了 interface 没声明的字段,直接报错。这能帮你抓拼写错误(比如把
mimeType写成mineType)。 - 一个心智模型:interface 是"给编译器看的说明书,不是给运行时看的代码"。写它的收益全在开发期——自动补全、改字段时的连锁报错、把"这个函数收什么形状"写进签名里;运行期的字节完全不受影响。pi 全仓库几百个 interface,编译成 JS 后一个都不剩,这也是 TS "零运行时开销的类型"的核心卖点。
2. 可选 ?、readonly、extends
2.1 直觉
真实对象很少字段全必填。有的字段可有可无(可选),有的字段只准读不准改(只读),还有的新 interface 只是在旧 interface 上加几个字段(继承)。TS 用三个修饰手段对应:字段名后加 ? 表示可选,字段前加 readonly 表示只读,interface B extends A 表示 B 拥有 A 的全部字段再追加自己的。
Python 对照:
?可选字段 ≈TypedDict的total=False或NotRequired[...];readonly≈Final/ frozen dataclass 字段(但同样只在编译期检查);extends≈ 类继承或TypedDict的子类扩展。
2.2 最小 demo
// 教学示例 — 非生产代码
interface Base {
readonly id: string; // 只读:创建后不能再赋值
name: string;
}
interface Account extends Base {
email: string;
nickname?: string; // 可选:可以不给
}
const a: Account = { id: "u1", name: "Ada", email: "a@x.io" };
// 没给 nickname,合法
console.log(a.nickname); // 类型是 string | undefined
a.name = "Ada L."; // ok,name 可改
// a.id = "u2"; // 编译错误:id 是 readonly
Account 通过 extends Base 自动拥有了 id、name,再补上 email、nickname。少写了 email 会报错;nickname 因为带 ? 可以整个省略。
2.3 正式化
- 可选
?:写在字段名后,nickname?: string。它把该字段的类型悄悄变成string | undefined——你读它时必须考虑"可能是 undefined"。省略该字段合法;显式写nickname: undefined也合法。 readonly:写在字段名前,readonly id: string。首次构造时必须赋值,之后任何再赋值都是编译错误。它只管顶层字段本身不能被重新指向;如果字段是个数组或对象,里面的内容仍可变(浅只读)。extends:interface B extends A { ... }让 B 继承 A 的所有成员。可以一次继承多个:extends A, B。子接口可以收窄父字段的类型,但不能改成不兼容的类型。- 三者可叠加:
readonly foo?: string就是"可选且只读"。
2.4 代码引用
先看可选字段。pi 的 agent 生命周期钩子 beforeToolCall 返回这个结果,两个字段全可选:
pi/packages/agent/src/types.ts:L60-L63 — 两个字段全可选的结果对象
export interface BeforeToolCallResult {
block?: boolean;
reason?: string;
}
返回 { block: true } 拦下工具、{ reason: "..." } 附带说明、返回 {} 什么都不做——因为两个字段都带 ?,三种形状都合法。
再看 readonly。agent 的运行时状态 AgentState 把一组"外部只能观察、不能篡改"的字段全标为只读:
pi/packages/agent/src/types.ts:L322-L347 — 一组 readonly 状态字段(中间字段已略)
export interface AgentState {
/** System prompt sent with each model request. */
systemPrompt: string;
/** Active model used for future turns. */
model: Model<any>;
// ...
readonly isStreaming: boolean;
/** Partial assistant message for the current streamed response, if any. */
readonly streamingMessage?: AgentMessage;
/** Tool call ids currently executing. */
readonly pendingToolCalls: ReadonlySet<string>;
/** Error message from the most recent failed or aborted assistant turn, if any. */
readonly errorMessage?: string;
}
对照:systemPrompt、model 是普通可写字段(调用方可以换模型);isStreaming、pendingToolCalls 等带 readonly——它们是内部状态的快照,只准读。注意 streamingMessage? 同时带了 ? 和 readonly:可选且只读。
最后看 extends。pi 测试里的计算器工具结果继承了通用的工具结果契约:
pi/packages/agent/test/utils/calculate.ts:L2-L7 — 用 extends 在通用契约上收窄
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
export interface CalculateResult extends AgentToolResult<undefined> {
content: Array<{ type: "text"; text: string }>;
details: undefined;
}
对照:CalculateResult 继承 AgentToolResult,拿到它的全部字段,然后把 content 收窄成"只含文本块的数组"、把 details 锁成 undefined。AgentToolResult<undefined> 里的 <undefined> 是泛型实参,这里只需理解成"给这个通用容器填了个类型参数",泛型机制见泛型。
2.5 洞察
?与| undefined几乎等价,但有微妙差别:带?的字段可以整个不写;而只写x: string | undefined的字段必须出现,只是值可以是 undefined。读的时候两者都得防 undefined。readonly是浅的:readonly items: string[]拦得住state.items = [],却拦不住state.items.push(...)。要深只读得用ReadonlyArray<T>/ReadonlySet<T>(上面pendingToolCalls正是ReadonlySet)。extends的父类型不兼容时会直接报错——这正是它的价值:改了父 interface,所有子 interface 会被编译器强制同步。这在 pi 这种几十个包互相依赖的 monorepo 里是刚需:改一处通用契约,编译器会精确点出所有需要跟着改的下游。- options 对象里"全字段可选"是一种设计选择:它让调用方只关心自己在意的几项。代价是编译器无法帮你保证"至少填了某一项",这类约束得靠运行时逻辑或更复杂的类型来表达。
3. type 别名、Record、与结构化类型
3.1 直觉
type 关键字给任意类型起个别名——包括对象形状,也包括联合、Record 之类。type X = {...} 和 interface X {...} 在描述对象形状上几乎可以互换。而 Record<K, V> 是内置工具类型,表示"键是 K、值是 V 的映射对象"。本章还要点破 TS 类型系统的核心脾气:结构化类型——两个类型兼不兼容,只看形状对不对得上,不看名字。
Python 对照:
type X = {...}≈ 用TypeAlias起别名;Record<string, string>≈dict[str, str](但对象在 JS 里是普通对象不是 dict);结构化类型 ≈ duck typing("长得像鸭子就是鸭子"),差别是 TS 在编译期就把这只鸭子验完了。
3.2 最小 demo
// 教学示例 — 非生产代码
// type 别名:描述对象形状
type Point = { x: number; y: number };
// Record:键值映射
type Env = Record<string, string>;
const origin: Point = { x: 0, y: 0 };
const cfg: Env = { HOME: "/root", LANG: "zh" };
// 结构化类型:形状匹配就兼容,名字无关
type Vec2 = { x: number; y: number };
const v: Vec2 = origin; // ok!Point 和 Vec2 名字不同,形状相同
// 多带字段的对象也能喂给"形状是子集"的参数(变量赋值场景)
function len(p: Point): number {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
const p3 = { x: 3, y: 4, z: 9 };
len(p3); // ok:p3 至少具备 x、y,多出的 z 被忽略
Point 和 Vec2 是两个名字不同的类型,但因为形状一模一样,Point 的值可以直接当 Vec2 用——这就是结构化类型。
3.3 正式化
type别名:type 名字 = 类型。右边可以是对象形状、联合(type A = X | Y)、Record、函数类型等等。interface只能描述对象/函数形状,type什么类型都能起别名。typevsinterface(描述对象时的经验法则):要"以后可能被别处extends、或想被同名声明合并"就用interface;要给联合、Record、元组之类起名就必须用type。pi 里两者都大量出现。一个只有interface有、type没有的能力是"声明合并":在两处写同名interface,TS 会把它们的字段合并成一个;type同名则直接报重复定义错误。库作者常靠这一点让使用方"扩充"自己的类型,而应用代码里通常用不到。Record<K, V>:内置工具类型,Record<string, string>等价于{ [key: string]: string },即"任意 string 键、值都是 string"的对象。这里只当它是个现成的"映射类型"用,尖括号里的泛型机制见泛型。- 结构化类型(structural typing):TS 判断"类型 A 能否赋给类型 B",看的是 A 是否具备 B 要求的所有成员且类型兼容,与类型的名字无关。这与 Java/C# 的"名义类型"(必须显式
implements)相反。对象字面量直接赋值时是例外(有多余属性检查),但经由变量中转就是纯结构比较。
3.4 代码引用
pi 用 type 别名给两个 Record 映射起了业务名字:
pi/packages/ai/src/types.ts:L100-L102 — 用 type 给 Record 映射起别名
/** Provider-scoped environment overrides. Values take precedence over process.env. */
export type ProviderEnv = Record<string, string>;
export type ProviderHeaders = Record<string, string | null>;
对照:ProviderEnv 就是"任意 string 键、string 值"的环境变量表(相当于 Python 的 dict[str, str]);ProviderHeaders 值类型是 string | null——null 用来表示"抹掉某个默认 header"。这里必须用 type 而非 interface,因为右边是 Record<...> 而不是 {...} 字面形状。
再看一个典型的 options 配置对象——一堆可选字段的"options bag":
pi/packages/agent/src/harness/types.ts:L80-L97 — 全可选字段的 options 配置对象
/** Curated provider request options owned by the harness and snapshotted per turn. */
export interface AgentHarnessStreamOptions {
/** Preferred transport forwarded to the stream function. */
transport?: Transport;
/** Provider request timeout in milliseconds. */
timeoutMs?: number;
/** Maximum provider retry attempts. */
maxRetries?: number;
/** Optional cap for provider-requested retry delays. */
maxRetryDelayMs?: number;
/** Additional request headers merged with auth and lifecycle headers. */
headers?: Record<string, string>;
/** Provider metadata forwarded with requests. */
metadata?: SimpleStreamOptions["metadata"];
/** Provider cache retention hint. */
cacheRetention?: SimpleStreamOptions["cacheRetention"];
}
对照:这是 JS/TS 里极常见的模式——把一大把可调项塞进一个对象参数,每个都带 ?,调用方想配哪项配哪项,不想配就整个省。headers?: Record<string, string> 复用了上一章的 Record 映射;因为字段全可选,{}、{ timeoutMs: 5000 }、{ transport: "sse", maxRetries: 3 } 都是合法的 AgentHarnessStreamOptions。这种 options 对象和结构化类型天然契合:调用方随手写个字面量对象,只要字段对得上形状,编译器就放行,不需要 new 任何具名类。
3.5 洞察
- 描述对象形状时,
type和interface九成场景可互换;记不住就默认interface,遇到联合/Record/元组再上type。pi 的惯例正是如此。 - 结构化类型意味着你几乎不需要显式声明"实现了某接口":一个模块只要导出的东西形状对得上,就自动满足某个 interface(pi 大量利用这点,让 API 实现模块无需
implements就满足ProviderStreams契约)。 - 别被
Record<string, X>的便利骗了:它放行任意字符串键,拼错的键名不会报错。键集合固定时,宁可用带具名字段的 interface 换取拼写检查。 - 结构化类型是把双刃剑:它让代码松耦合、少写样板,但也意味着"两个业务含义完全不同、却恰好同形状"的类型会被判为兼容。真要区分,可以给其中一个加一个独有的字面量标签字段(就像第 1 章的
type: "text"),让形状不再重合——这也正是下一篇可辨识联合的起点。
讨论 / Comments
评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。