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

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

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

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

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

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

讨论 / Comments

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