TS·06 类与模块:封装、契约、与 import/export

前几篇我们把 TypeScript 的类型系统拆开讲透了:值与函数、interface 与 type(形状)、联合、窄化、泛型。这一篇转向代码的组织单位:用 class 把状态与行为封装在一起,用 implements/extends 表达契约与继承,再用 ESM 的 import/export 把这些拼成模块。

作为 Python 程序员,class 这个词你早就熟。这里的重点是 TS 特有的三样东西:访问修饰符是编译期的(private 不像 Python 的 _name 只是约定,编译器会真的拦你)、implements 让类去满足一个接口契约、以及 import type 这种"只搬类型、运行时零成本"的导入——它在 Python 里没有直接对应物。读完你就能看懂 pi 里 Agent 类怎么管理内部状态、FileError 怎么继承 Error、以及一个包的 index.ts 是怎么把几十个文件汇总成一个入口的。

1. 类:字段、构造器、访问修饰符

1.1 直觉

TS 的 class 和 Python 的 class 概念一致:把数据(字段)和操作数据的方法打包成一个自带状态的对象。差别在语法细节上。Python 里字段是在 __init__ 里通过 self.x = ... 隐式产生的,类体里根本看不到有哪些字段;TS 要求你先在类体里声明字段(private messages: AgentMessage[] = []),再在 constructor 里赋值。这多出来的声明不是啰嗦——它让类型检查器和读代码的人一眼看清这个对象有哪些字段、各是什么类型,等于把 Python 里散落在 __init__ 各处的 self.x= 集中成一张"字段清单"。此外 TS 给字段加了访问修饰符:private/public/readonly,用来表达封装意图,而且——这是和 Python 最大的不同——是编译期强制的。

1.2 最小 demo

// 教学示例 — 非生产代码
class Counter {
  private count: number;            // 私有字段:外部读不到
  public readonly step: number;     // 公开但只读:构造后不能改

  constructor(start: number, step = 1) {
    this.count = start;             // 用 this.x 赋值,和 Python 的 self.x 一样
    this.step = step;               // step 有默认值 1(参数默认值)
  }

  tick(): number {
    this.count += this.step;
    return this.count;
  }
}

const c = new Counter(10);          // new 关键字创建实例
c.tick();                           // 11
// c.count;    // ✗ 编译错误:count 是 private
// c.step = 2; // ✗ 编译错误:step 是 readonly

Python 对照:Python 的 self._count 私有只是命名约定,运行时照样能访问;TS 的 private 是编译期强制,tsc 会直接报错(但注意:编译成 JS 后运行时并不拦——见 1.5)。

1.3 正式化

字段声明:写在类体顶部,名字: 类型,可选 = 默认值。没有默认值又没在构造器里赋值、且开了 strictPropertyInitialization 时,编译器会报"字段未初始化"。

构造器:方法名固定为 constructor,new C(...) 时自动调用。参数可以有默认值(step = 1,等价于 Python 的 def __init__(self, step=1))。

访问修饰符(写在字段/方法名前):

??(空值合并):a ?? b 表示"a 若为 null/undefined 就取 b,否则取 a"。它和 || 的区别是:?? 只在 null/undefined 时回退,而 0、""、false 这些"假值"会被保留。给可选构造参数配默认值时,options.mode ?? "default" 是标准写法。

1.4 代码引用

pi 的 agent 包里有个小而完整的例子:PendingMessageQueue——一个私有数组 + 构造器注入的 mode + 几个公开方法。它是 Agent 用来缓存"用户中途插入的消息"的队列。

pi/packages/agent/src/agent.ts:L123-L138 — 私有字段 + 构造器注入 + 公开方法

class PendingMessageQueue {
	private messages: AgentMessage[] = [];
	public mode: QueueMode;

	constructor(mode: QueueMode) {
		this.mode = mode;
	}

	enqueue(message: AgentMessage): void {
		this.messages.push(message);
	}

	hasItems(): boolean {
		return this.messages.length > 0;
	}

private messages 是内部状态,外界只能通过 enqueue/hasItems(公开方法,public 是默认所以省略)去操作它——这就是封装。mode: QueueMode 由构造器注入,QueueMode 其实是个字符串字面量联合 "all" | "one-at-a-time"(pi 全仓禁用 enum,用联合代替)。

再看真正的 Agent 类头部——它把 readonly 和 ?? 默认值都用上了:

pi/packages/agent/src/agent.ts:L171-L174, L210-L223 — Agent 的私有只读字段与构造器默认值(中间省略大量公开字段)

export class Agent {
	private _state: MutableAgentState;
	private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();
	private readonly steeringQueue: PendingMessageQueue;
	// ... 省略大量 public 回调字段 ...
	constructor(options: AgentOptions = {}) {
		this._state = createMutableAgentState(options.initialState);
		// ...
		this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
		this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
		this.transport = options.transport ?? "auto";
	}

对照:private _state 是 agent 的内部可变状态,外部拿不到;private readonly listeners 表明"这个 Set 引用建好后不再替换"(但 Set 内容仍可增删——readonly 锁的是字段绑定,不是对象内部)。constructor(options: AgentOptions = {}) 给整个参数对象一个默认空对象,于是 new Agent() 无参也能调;里面每个 options.X ?? 默认值 就是 1.3 说的空值合并回退。

1.5 洞察

2. implements 与 extends(含 class extends Error)

2.1 直觉

有了类之后,两种复用/约束关系登场。implements 是"我承诺满足某个接口契约"——类必须提供接口列出的所有成员,否则编译报错;它不带来任何实现,只做检查,像一份必须逐项打钩的清单。extends 是"我继承一个父类"——拿到父类的字段和方法,可覆写,可用 super(...) 调父类构造器。Python 里两者都靠 class Child(Parent)(以及 Protocol 的隐式满足);TS 特意把"契约"(implements,显式声明满足某接口、但零实现)和"继承"(extends,拿父类的真实实现)在语法上分成两个关键字,读代码时一眼能分清"这是形状约束"还是"这是代码复用"。一个特别常见的 extends 用法是自定义错误类:class FileError extends Error,让你能 throw 一个既是标准 Error、又带结构化字段(错误码、路径)的对象。

2.2 最小 demo

// 教学示例 — 非生产代码
interface Named {          // 一个契约:必须有 name
  name: string;
  greet(): string;
}

// implements:类承诺满足 Named 契约
class Person implements Named {
  constructor(public name: string) {}   // 参数属性:等价于声明+赋值 this.name
  greet(): string {
    return `Hi, I'm ${this.name}`;
  }
}

// extends:自定义错误继承内置 Error
class NotFoundError extends Error {
  constructor(public path: string) {     // 额外携带一个结构化字段
    super(`not found: ${path}`);         // 必须先调 super 才能用 this
    this.name = "NotFoundError";
  }
}

throw new NotFoundError("/tmp/x");       // err.path 可读,err instanceof Error 为 true

Python 对照:class Person implements Named 像 Python 显式写 class Person(Named) 且 Named 是个 Protocol——但 TS 的 implements 不继承任何实现,纯粹是编译期"你没漏实现吧"的核对。super(...) 与 Python 的 super().__init__(...) 对应。

2.3 正式化

implements I:放在类名后,class C implements I。可同时满足多个:implements A, B。编译器逐一核对 I 的每个成员在 C 里都有且类型兼容,缺一个就报错。implements 只检查、不注入——接口里就算写了方法签名,类里也得自己实现。

extends P:单继承(TS 类只能 extends 一个父类)。子类构造器若访问 this 之前必须先调 super(...),把父类构造器跑完。可用 override 关键字(开启 noImplicitOverride 时推荐)标注覆写的方法。

参数属性(demo 里 constructor(public name: string)):在构造器参数前加 public/private/readonly,TS 会自动声明同名字段并赋值,省掉"声明 + this.x = x"的样板。这是 TS 独有的语法糖。

class X extends Error:JS 内置 Error 有 message、name、可选 cause。继承它并在 super(message, options?) 里传消息,再挂自己的字段(如错误码 code、路径 path),就得到既能 throw/catch、又携带结构化信息的错误类型。

2.4 代码引用

pi 的 harness 层把所有文件系统错误统一成一个 FileError,继承自 Error 并带一个稳定的错误码:

pi/packages/agent/src/harness/types.ts:L121-L133 — class extends Error:结构化字段 + super 调用

export class FileError extends Error {
	/** Backend-independent error code. */
	public code: FileErrorCode;
	/** Absolute addressed path associated with the failure, when available. */
	public path?: string;

	constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
		super(message, cause === undefined ? undefined : { cause });
		this.name = "FileError";
		this.code = code;
		this.path = path;
	}
}

对照:extends Error 让 FileError 实例既是 Error(instanceof Error 成立、能被通用 catch 捕获),又多出 public code(错误码,类型是字面量联合 FileErrorCode)和 public path?(可选路径——注意 ? 表示这个字段可能是 undefined)。构造器第一行 super(...) 把 message/cause 交给父类,之后才允许 this.code = code。

再看 implements 与 extends 一起出现的例子。pi 的 ai 包有个泛型事件流类,它实现了 JS 的 AsyncIterable 契约,又被一个具体子类继承:

pi/packages/ai/src/utils/event-stream.ts:L4-L18 — 泛型类 implements AsyncIterable 契约

export class EventStream<T, R = T> implements AsyncIterable<T> {
	private queue: T[] = [];
	private waiting: ((value: IteratorResult<T>) => void)[] = [];
	private done = false;
	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;
	}

pi/packages/ai/src/utils/event-stream.ts:L69-L82 — 子类 extends 泛型父类,构造器调 super 填死泛型参数

export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
	constructor() {
		super(
			(event) => event.type === "done" || event.type === "error",
			(event) => {
				if (event.type === "done") {
					return event.message;
				} else if (event.type === "error") {
					return event.error;
				}
				throw new Error("Unexpected event type for final result");
			},
		);
	}
}

对照:EventStream<T, R = T> 是个泛型类(R = T 是泛型默认值);implements AsyncIterable<T> 承诺它可被 for await...of 迭代——编译器会核对它有 [Symbol.asyncIterator] 方法(在省略的下文里)。子类 AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> 把两个泛型参数填成具体类型,再在 constructor 里用 super(...) 把父类需要的两个回调传上去。这正是 2.3 说的"super 跑完父类构造器"。

2.5 洞察

3. 模块:具名导出、import type/export type、barrel、declare module

3.1 直觉

一个 .ts 文件就是一个模块。默认里面的东西都是文件私有的,想让别的文件用,得 export;想用别人的,得 import。这就是 ESM(ECMAScript Modules),和 Python 的 from x import y 心智一致。但 TS 多一层:因为它严格区分"类型"(只在编译期存在)和"运行时的值"(编译后真的会执行),于是有 import type——只搬类型注解,编译成 JS 时整行被擦掉,运行时零成本、连模块副作用都不触发。这层区分在 Python 里不存在,是 TS 学习者最容易忽略的一点。此外还有两个常见的组织手法:barrel(一个 index.ts 把整个目录 export * 汇总成单一入口,相当于 Python 的 __init__.py 做 re-export,让使用者不必关心内部文件怎么拆)和 declare module(往一个已有模块里"补充声明",做类型层面的合并,常用于给第三方库补类型或给框架留扩展点)。

3.2 最小 demo

// 教学示例 — 非生产代码

// ---- file: shapes.ts ----
export interface Point { x: number; y: number }   // 具名导出一个类型
export function origin(): Point {                  // 具名导出一个函数(值)
  return { x: 0, y: 0 };
}

// ---- file: main.ts ----
import { origin } from "./shapes.ts";              // 导入值:运行时真的会执行
import type { Point } from "./shapes.ts";          // 导入类型:编译后此行消失
// 也可合并:import { type Point, origin } from "./shapes.ts";

const p: Point = origin();

// ---- file: index.ts(barrel)----
export * from "./shapes.ts";                       // 把 shapes 的导出原样再导出

Python 对照:barrel 的 export * from "./shapes.ts" ≈ 在 __init__.py 里 from .shapes import *,让外部写 from pkg import Point 而非 from pkg.shapes import Point。而 import type 在 Python 里没有直接对应——Python 的 import 总是运行时真执行;TS 的 import type 保证被编译器擦除,连模块副作用都不触发。

3.3 正式化

具名导出/导入:export 放在声明前(export function f...),或集中写 export { a, b }。导入用 import { a, b } from "路径"。注意 pi 的导入路径带显式 .ts 后缀(如 "./shapes.ts"),这是它的模块解析设置要求,和很多省略后缀的项目不同。

import type / export type:在 import/export 后加 type,表示这一项只在类型层面存在,编译产物里被完全擦除。两种粒度:整行 import type { X } from "...",或行内 import { type X, fn }(只把 X 标成类型、fn 仍是值)。好处有二:一是运行时零成本、不引入无谓的模块加载;二是配合 pi 开的 erasableSyntaxOnly/verbatimModuleSyntax,明确区分"类型"和"值"能避免打包器把纯类型误当成运行时依赖。

barrel:一个只做转发的 index.ts,用 export * from "./a.ts" 批量转发,或 export { X } from "./a.ts" 精选转发。它给整个包一个单一公开入口,内部文件怎么拆分对使用者透明。

declare module "...":声明合并的一种。用来给一个已存在的模块追加类型声明——常见于给第三方库补类型,或(如 pi)用"接口在别处被扩充"的模式做插件化的类型注册。它是纯类型构造,不产生运行时代码。这里点到为止,知道读到它时它在"往别的模块里加声明"即可。

3.4 代码引用

先看 pi agent 包的 barrel 入口——大量 export * 汇总子模块,并夹杂"类型 + 值混合"的精选导出:

pi/packages/agent/src/index.ts:L1-L13 — barrel:export * 批量转发 + 混合 type/值的精选导出

// Core Agent
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
	type BranchPreparation,
	type BranchSummaryDetails,
	type CollectEntriesResult,
	collectEntriesForBranchSummary,
	generateBranchSummary,
	prepareBranchEntries,
} from "./harness/compaction/branch-summarization.ts";

对照:export * from "./agent.ts" 把 agent.ts 里所有导出(Agent 类等)原样再导出,于是外部只需 import { Agent } from "@earendil-works/pi-agent-core",不必知道它藏在 agent.ts。下面那个 export { ... } 块里,type BranchPreparation 前缀 type 表明它是纯类型、编译后擦除,而 collectEntriesForBranchSummary 等是运行时函数——同一个 export {} 里两类混排,靠 type 前缀区分。

ai 包的入口开头则从第三方库 typebox re-export,export type 与 export 各司其职:

pi/packages/ai/src/index.ts:L1-L2 — export type 搬类型 / export 搬值,泾渭分明

export type { Static, TSchema } from "typebox";
export { Type } from "typebox";

对照:Static、TSchema 是 typebox 的类型(编译期用于从 schema 派生静态类型),用 export type 转发、运行时不留痕;Type 是 typebox 的运行时对象(用来构造 schema),用普通 export 转发。pi 把二者拆成两行,正是 verbatimModuleSyntax 下的规范写法。

在消费端,行内混合形式最常见——测试工具里同一行既拿类型又拿值:

pi/packages/agent/test/utils/calculate.ts:L1 — 行内 import:type Static 是类型、Type 是值,同一条 import

import { type Static, Type } from "typebox";

对照:一条 import 里,type Static 标为类型(编译后从这条导入里消失),Type 是运行时值(保留)。若这行只剩 type Static,编译器会把整条 import 擦掉;因为还有 Type 这个值,导入才在运行时保留。

最后是 declare module 的真实用法。pi 用它给核心的 types.ts 补充消息类型,做插件化的声明合并:

pi/packages/agent/src/harness/messages.ts:L54-L61 — declare module:往已有模块的接口里合并新成员

declare module "../types.ts" {
	interface CustomAgentMessages {
		bashExecution: BashExecutionMessage;
		custom: CustomMessage;
		branchSummary: BranchSummaryMessage;
		compactionSummary: CompactionSummaryMessage;
	}
}

对照:types.ts 里原本定义了一个空的 CustomAgentMessages 接口做扩展点;这里 declare module "../types.ts" 打开那个模块,往同名接口里追加四个成员。接口的这种"同名合并"就是声明合并——纯类型操作,不生成任何运行时代码,却让核心类型"认识"了 harness 层新增的消息种类。

3.5 洞察

讨论 / Comments

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