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))。
访问修饰符(写在字段/方法名前):
public(默认)——处处可访问,写不写都一样。private——只有类内部能访问,子类都不行。protected——类内部 + 子类可访问。readonly——可与上面组合(private readonly x),只允许在声明处或构造器里赋值一次,之后只读。
??(空值合并):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 洞察
private/readonly是编译期擦除的:tsc会拦住越权访问,但编译成 JS 后这些修饰符消失,运行时并不阻止。要运行时真私有,用 JS 原生的#field语法(井号私有字段)。pi 选择编译期private,因为它对读代码的人已经足够表达意图。- 字段必须先声明:从 Python 迁移过来最容易忘。TS 不允许在构造器里凭空
this.newField = 1而类体没声明newField——会报错。 ??vs||:配置默认值一律用??。若某选项合法值包含0或"",用||会把它们错误地当成"没传"而回退,这是常见 bug。- 构造器参数带默认值 = 可选:
constructor(options: AgentOptions = {})让new Agent()无参可调;等价于 Python 的def __init__(self, options=None)再options = options or {},但 TS 直接写在签名里,类型也据此推断为可选。
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 洞察
implements不给实现,extends给:想复用父类代码用extends;只想"保证形状对上"、类各写各的,用implements。二者可叠加(如class C extends B implements I)。- 自定义错误一定
super(message)打头,并设this.name:不设name,打印栈时会显示成通用的 "Error";pi 每个错误类都手动this.name = "FileError"。 - 参数属性省样板但别滥用:
constructor(public name: string)很方便,但字段多、有默认值或需要文档注释时,分开声明更清晰——pi 的FileError就选择显式声明public code并加 JSDoc。
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 洞察
- 能用
import type就用:纯类型的导入标type,既表意清晰又保证运行时零开销;pi 开了verbatimModuleSyntax,漏标会直接报错。判断标准:这个名字只出现在类型位置(注解、泛型参数、implements)就是类型,被new/调用/取值就是值。 - barrel 的代价:
export *方便,但会让"某文件到底导出了啥"变得不透明,也可能拖累打包器的 tree-shaking。pi 的取舍是入口用 barrel、但对有歧义的项改用精选export { ... }。 declare module是进阶工具:日常写业务基本用不到,主要出现在"给第三方库补类型"或"框架留扩展点"两处。读 pi 时认得它在做类型合并即可,不必急着自己写。- 导入路径带
.ts后缀是 pi 的选择:多数教程里import ... from "./shapes"省略后缀;pi 的模块解析要求写全"./shapes.ts"。照抄 pi 代码时保留后缀,自己起项目时按各自tsconfig约定即可。
讨论 / Comments
评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。