关于 typescript-standards
为 TypeScript 代码库提供设计与编写规范,面向 Agent,也适用于新增和重构行为。重点涵盖类型、错误处理、边界解析、测试及最小化改动。
主要功能
- 01类型与模块设计规范
- 02边界解析与 Schema 使用
- 03错误分类与处理原则
- 04测试与可观测性指导
- 05强调最小化代码改动
适用场景
- 01为 Agent 提供 TypeScript 编码约束
- 02重构代码时统一错误处理与解析方式
- 03设计模块、类型和测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ alexander-zuev/ agent-skills --skill typescript-standards安装后在 Claude Code 中输入/typescript-standards确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/modules-and-ports.mdstyle-and-files.mdtesting.md详细介绍
这些标准描述了如何在此代码库中设计和编写 TypeScript 代码。它们尤其面向 Agent:添加模式、库、适配器或抽象之前,先检查现有代码;但所有新增和重构的行为都应遵循这些标准。只有在与这些标准兼容时,才遵循现有约定。
规则归属
本规范负责类型、模式、模块设计、注释和通用测试。 FSD、React、查询、变更和 UI 约定请参阅 tanstack-frontend。 应用边界、传输契约、重试和持久化请参阅 cloudflare-backend。
这些规范各自定义了所负责的主题。内部 Result 约定并不规定传输封装格式。 明确的任务指令和全局工作流约束仍然适用。 将这些标准应用于新增和重构的行为。在边界处控制与之不兼容的现有模式。 保持无关代码不变。需要记录长期架构决策时,请编写 ADR。
核心原则
- 默认使用类型化抛出错误和被拒绝的 Promise。保留既有的 Result 或 Effect 契约。
- 解析而非验证。尽早解析,并尽可能靠近组合根或应用根的位置。不要只做验证,然后丢弃得到的信息。
- 在实际可行时,让非法状态无法表示。
- 相较于依赖约定的不变量,优先采用正确性由构造保证的 API。
- 使用品牌类型/精化类型/领域类型来避免实际可能发生的错误,例如混用标识符或单位、绕过解析,或构造无效值。
- 优先选择组合而非继承。
- 优先选择命令式外壳/函数式核心。
- 设计深入且内聚的模块,降低调用方的负担。
- 通过真实接缝测试行为;避免模块模拟和由间谍驱动的测试。
- 让代码便于人和 Agent 查找。
复用与操作契约
编写通用逻辑之前,先确定哪个运行时、框架、SDK 或已安装依赖可以负责实现它。 检查其导出内容和文档。如果技术栈没有合适的能力,可考虑使用维护良好的库。 最后才编写自定义逻辑,并说明理由。
操作只有在承诺的效果已经实现后才能返回。 修复该操作本身,不要在下游添加轮询、延迟注册表或完成等待器。 只有当事件源无法直接提供完成状态时,才在事件的所属方将事件桥接为 Promise。 异步操作可以返回已接受的任务,但其契约必须区分接受任务与任务完成。
签名必须准确声明函数接收的内容。不要传入比参数类型允许范围更广的对象。 使用已声明的 API 响应类型。在通过临时守卫重建响应类型之前,先检查客户端配置和泛型推断。 对于自有 API,应在边界处补齐缺失的模式或类型。使用模式解析不可信输入。
适应现有代码库
添加新模式或库之前,先检查仓库中以下方面的现有选择:
- 错误处理
- 模式解析
- 依赖注入
- 测试
- 可观测性
- 适配器/服务
- 模块布局
将这些标准应用于所有新代码,以及正在重构的完整行为。不要仅仅为了保持一致而保留较弱的模式。保持无关的旧代码不变,并在最近的边界处转换不兼容的模式。
大多数应用使用抛出错误和被拒绝的 Promise。新代码和重构代码应遵循这一约定。
依赖 better-result(包括传递依赖)并不足以成为采用 Result 的理由。
保留现有的日志记录、追踪、指标和错误报告钩子。
最小差异
一次更改只触及实现约定行为所需的行。其他所有现有行都必须保持字节级一致。 不要移动、重新缩进、重新换行、改写、重命名或重新输入现有代码。只在其周围添加新代码;不要为了适配而重构现有代码。 仅当用户同意或确实能带来改进时,才进行未请求的清理;并将其作为单独的更改。 只有用户要求重构时,才可例外。如果更改迫使其他行移动,请先说明原因。
未请求的编辑会让评审难以看清真正的更改,并使 git blame 指向错误的提交。重命名导出的符号也可能破坏调用方。
错误与失败
定义每项操作的失败情形
考虑已知的失败情形,即使 TypeScript 无法在返回类型中声明抛出的错误。 使用带稳定标签或代码且上下文有用的类型化错误。绝不要根据错误消息文本对错误分类。
实现前,明确:
- 操作可能产生哪些失败,包括取消和部分完成。
- 调用方必须区分哪些失败,以及每种失败需要采取什么操作。
- 哪一层负责转换外部失败,哪一边界负责记录最终错误。
- 重复执行是否安全,以及由哪一层负责重试。
- 哪些有针对性的测试可以证明重要的失败行为和清理工作。
使用 @throws 文档说明不明显的抛出型失败。不要添加未使用的错误联合类型来暗示存在受检异常。
必要时,在所属适配器中转换外部失败。保留调用方所需的原因和区别。
预期的缺失、完成和无操作结果可以作为普通数据,而不是错误。
有限使用 Result
仅当显式组合结果能带来具体好处时,才对独立操作使用 better-result。
例如,细致的重试分类,或包含多个可恢复结果的特殊工作流。
仅有重试需求并不意味着必须使用 Result。先检查项目现有的重试能力。
说明局部收益,并将转换保留在该操作的边界。不要为了满足此规范而将 Result 扩散到调用方。
不要仅因为该依赖可用或存在预期失败,就采用它。
在整个应用中采用 Result 需要单独获得明确批准。
保留既有的 Result 或 Effect 代码;此默认做法不要求将其迁移为抛出错误。
传输转换
传输适配器使用传输自身的失败通道。共享中间件负责序列化和错误转换。 当独立操作返回 Result 时,其适配器可以提取成功结果,或将类型化错误抛给现有中间件。 使用项目现有的转换辅助函数。不要添加成功包装,也不要在每个函数中重复序列化。 更改此契约前,请先阅读后端规范;添加服务端桩代码时也不例外。
缺陷可以抛出或触发 panic
生产代码中绝不要使用 throw new Error(...)。使用带稳定标签或代码的类型化错误。
修改相关代码时,迁移原始抛错。刻意用于测试和故障探测的抛错除外。
类型化抛错可以表示预期失败。只有在无法正确执行时,才使用 panic 和缺陷辅助函数。缺陷包括:
- 违反内部不变量
- 不可能到达的分支
- 临时的
notYetImplemented路径 - 灾难性的运行时条件
已知的配置失败应使用类型化错误;组合根应安全地报告这些错误并终止启动。
如有现成的共享缺陷辅助函数,请使用;否则使用项目结果库中的 panic 辅助函数:
export function casesHandled(unexpectedCase: never): never;
export function shouldNeverHappen(msg?: string): never;
export function notYetImplemented(msg?: string): never;
使用 casesHandled 处理穷尽联合类型。若项目已有这些辅助函数,请避免使用 absurd 之类的名称或一次性的 assertNever 辅助函数。
自定义错误
预期会发生的失败应使用自定义标记错误,通常继承自:
Errorbetter-result中的TaggedError- Effect 代码库中的
Schema.TaggedErrorClass
自定义错误应包含:
- 使用 'as const' 声明的稳定标记
- 有用的消息
- 结构化的上下文字段
- 安全的遥测字段
- 可选的
cause: unknown
示例:
export class UserStoreUnavailable extends Error {
readonly _tag = "UserStoreUnavailable" as const;
constructor(
readonly operation: "findActiveByEmail",
readonly provider: "postgres",
readonly cause: unknown,
) {
super(`User store unavailable during ${operation}`);
}
}
在模块边界处保持错误联合类型精确:
Result<User, UserNotFound | UserStoreUnavailable>
除入口、编排、日志记录和渲染层附近外,避免使用宽泛的 AppError 式类型。
包装并重新抛出
仅在跨越某个端口,且其调用方不应看到内部错误类型时才进行包装。将每个内部标记映射到调用方已有分支处理逻辑的一个外部标记。将内部错误保留为 cause。调用方应匹配外部标记;不得通过解包 cause 来进行判断。
以下情况不要包装:
- 仍处于拥有这些内部标记的层中
- 每种内部失败都会变成同一个外部标记(这不准确:无效输入并非“不可用”)
- 包装只是为了避免导入内部类型
以下示例保留了现有的基于抛出异常的边界约定。 对于内部 Result 端口,应以类型化值的形式返回映射后的错误。
良好 — 在端口处映射:
async function startGrok(signal: AbortSignal): Promise<AcpTransport> {
try {
return await AcpTransport.start({ command: "grok", signal, /* … */ })
} catch (cause) {
throw new CodingAgentUnavailableError({ cause })
}
}
启动失败。编码 Agent 端口没有 ACP 启动类型。Unavailable 才是调用方可以据此采取行动的事实。
不良 — 捕获所有错误后重新抛出:
async function grokRequest(transport: AcpTransport, input: Request): Promise<Response> {
try {
return await transport.request(input)
} catch (cause) {
throw new CodingAgentUnavailableError({ cause })
}
}
request 已经对超时、进程退出和 JSON-RPC 拒绝进行了分类。这个包装抹去了这些区别。错误的 configId 看起来就像“Grok 服务不可用”。请改为调用 transport.request,或者将 AcpRpcError 映射到 CodingAgentRequestError,并且只将传输、超时和退出标记映射到 CodingAgentUnavailableError。
敏感数据、遥测与调试
优先使用端到端的结构化追踪,贯穿请求、作业、工作流、应用模块、适配器和外部调用。
追踪和日志记录应通过安全字段帮助诊断失败:
- 领域 ID
- 操作名称
- 依赖项/提供方名称
- 状态标记
- 重试次数
- 类型化错误标记
- 安全摘要
不要在错误、追踪、日志或快照中放入机密信息。
对令牌、API 密钥、密码、原始凭据和机密等敏感值使用 Redacted<T> 包装器。在 Effect 代码库中优先使用 Effect 的 Redacted.Redacted,或使用本地共享的 Redacted<T> 包装器。
在边界处包装敏感值,只在需要原始值时才解包,通常是在进行外部调用的适配器内部。
解析,而非验证
边界代码应将未知或结构较松散的输入转换为应用或领域类型,再将其传入内部代码。
只有在协议投影的形状或含义差异足够大、因而有用时,才使用单独的协议投影。DTO 用于描述文本中的边界角色;切勿在符号名称中使用 DTO 或 Dto。应根据其实际的协议或持久化含义命名符号,例如 CreateUserRequest、StripeCustomerResponse 或 UserRecord:
unknown -> CreateUserRequest -> CreateUserInput -> EmailAddress/UserId/etc.
否则,直接将其解析为应用输入:
unknown -> CreateUserInput
不要在整个应用中传递由 schema 推断出的传输形状:
unknown -> z.infer<typeof CreateUserSchema>
使用能够保留含义的名称:
- 对不可信或结构较松散的输入,使用
parseX(input): Result<X, ParseXError> - 对由已有类型的部分构造智能构造函数,使用
makeX(...)/createX(...) - 对真正的谓词,使用
isX(value): value is X - 少用
assertX(...),主要限于测试/框架边界
如果函数返回的是经过精化的值,避免使用 validateX。它实际上解析了某些内容。
Schema
将 Schema 库用作边界解析器,而不是散布在核心逻辑中的临时验证器。
偏好:
- 如果仓库已有成熟的 Schema 库,就使用它
- 在 Effect 代码库中使用 Effect Schema
- 对通用辅助工具,优先兼容 Standard Schema
- 否则优先使用 Zod 4
- 对于简单的领域类型,如果手写智能构造函数或解析器更清晰,就采用这种方式
Schema 解析应尽可能生成精炼的领域类型和类型化自定义错误。
对于 Zod 4:
- 使用顶层格式辅助函数:
z.uuid()、z.email()、z.url()、z.httpUrl()、z.iso.datetime()、z.iso.date()和z.iso.time()。 - 在适用时使用
z.uuidv7()、z.nanoid()、z.ipv4()、z.base64()、z.jwt()和z.stringbool()。 - 使用
{ error: "message" }设置错误消息,而不是使用message或invalid_type_error。 - 使用带品牌的 ID Schema(例如
UserIdSchema),而不是直接使用z.string()。
带品牌的类型与正确构造
如果带品牌或精炼类型能够避免实际可能出现的误用或无效构造,就应使用它们,尤其适用于:
- ID:
UserId、OrgId、WorkflowId - 已解析的字符串:
EmailAddress、NonEmptyString、Url - 受约束的数字:
PositiveInt、Cents、Percentage - 单位:
Milliseconds、Bytes、UsdCents
通过解析器或智能构造函数构造带品牌的值。如果存在领域类型,请避免传递原始字符串或数字。
函数需要值时,应避免使用可选值、空值或 undefined。将可选性推到更外层。调用前先分支处理或解析。
除非部分性本身就是领域概念,否则不要将 Partial<T> 用作应用或领域输入。应为每种操作定义明确的输入类型。
一个事实,一种表示
在行、契约、事件、缓存、状态、属性和配置中,每个独立事实只表示一次。
保留规范事实或权威结果。在所属层或使用层派生确定性值。如果派生逻辑包含业务策略,则公开结果。不要为了方便而重复其输入。
添加字段前,请先确认:
- 这是一个独立事实吗?
- 能否通过现有字段精确确定它?
- 如果这些值不一致,会发生什么?
- 哪个值是规范值?
- 哪一层负责派生?
仅在外部兼容、不可变审计快照、经测量的性能优化或无法获取私有源数据时,才允许冗余。
明确规范数据源。以原子方式同步,并在可能时强制执行不变量。测试值不一致的情况。
状态机与布尔盲区
当实体具有明确的生命周期状态时,应使用带标签的联合类型或等效值类进行建模。
推荐:
type Invoice =
| { readonly _tag: "Draft"; readonly id: InvoiceId; readonly lines: NonEmptyArray<LineItem> }
| { readonly _tag: "Sent"; readonly id: InvoiceId; readonly sentAt: Instant }
| { readonly _tag: "Paid"; readonly id: InvoiceId; readonly paidAt: Instant };
避免:
type Invoice = {
readonly isSent: boolean;
readonly isPaid: boolean;
readonly sentAt?: Date;
readonly paidAt?: Date;
};
避免使用控制行为的布尔参数:
createUser(input, true);
优先使用具名选项或领域类型:
createUser(input, { emailVerification: "skip" });
布尔值作为清晰的谓词返回值时没有问题:
isExpired(token): boolean;
hasPermission(user, permission): boolean;
模块与抽象
领域模块、应用服务模块和适配器模块描述的是职责,而不是文件夹。在设计或评审模块、端口或适配器之前,请阅读模块与端口。其中定义了各角色、深层模块、由应用拥有的端口、适配器复用审查以及仓库。
函数式核心、命令式外壳与入口点
领域模块构成函数式核心。应用服务模块和适配器模块构成命令式外壳,但只有适配器包含特定于技术的关注点。这样,同一应用操作便可在 REST、CLI、GraphQL、工作进程及其他入口点中重复使用。
函数式核心包含领域解析器、不变量、状态转换、计算、组合子和决策函数。它避免 I/O、隐藏依赖、环境时间或随机性、抛出预期失败以及框架特定的关注点。
命令式外壳有两项不同的职责:
- 应用服务应用应用策略,并通过显式端口按顺序执行副作用。
- 适配器解析或投影边界值、分类外部失败,并执行具体的 I/O。
入口适配器应是精简的协议转换层。它们解析特定于协议的输入,调用领域模块解析器以获取经过细化的值,在涉及应用策略或副作用时调用应用服务,并呈现特定于协议的输出。纯操作可以直接调用领域模块,如模块与端口中所述。不要在控制器、解析器、命令或处理器中重复业务规则。
在身份验证和授权中,入站适配器验证边界凭据,并生成已解析的身份,例如 Principal、Session 或 CommandActor。领域模块可以基于已解析的领域值定义纯权限决策。应用服务收集所需上下文,并在执行应用操作时强制执行这些决策。适配器将缺失或无效的凭据以及被拒绝的操作映射为特定于协议的结果;它们不定义权限策略。
工作流、事务与幂等性
对于简单的单边界操作,使用普通函数调用或数据库事务。
当进度必须在进程丢失或重新投递后仍能保留,或者操作需要长时间延迟、补偿、可恢复性、计时器、人工审批、跨服务协调或多个事务边界时,使用 saga 或持久化工作流。短暂重试本身并不需要持久化工作流机制。
适配器负责安全、短暂的技术性重试。应用服务决定是否应再次尝试某项应用操作。持久化工作流负责必须跨崩溃、延迟或重新投递保留的重试。
不要在网络调用或长时间运行的操作期间保持数据库事务处于打开状态。
任何可能重试且可从外部观察到的变更或状态转换,都需要明确的幂等策略:
- 幂等键
- 自然唯一约束
- 去重记录
- 状态机转换保护
- 事务性 outbox/inbox
重试不应依赖“可能安全”的副作用。
测试
绝不要使用 vi.mock 或 jest.mock;应通过真实的接缝进行测试。在编写、审查或加速测试之前,请阅读测试。
这是每个项目的默认要求。项目测试文档只记录例外情况。
TypeScript 风格与安全性
禁止使用 any、非空断言,以及没有 SAFETY: 注释的 as 类型断言。在编写或审查 TypeScript 之前,请阅读风格与文件。其中涵盖编译器设置、类型断言、导入、导出和文件名。
注释和 JSDoc
每条非 JSDoc 注释都必须严格占一行。说明原因:某项约束、被否决的替代方案或外部规则。 删除描述代码已经清楚展示的内容的注释。
JSDoc 默认写两到三行。只有在调用方需要了解不明显的契约、副作用、顺序或生命周期规则时,才增加行数。
为关键模块和导出的架构边界编写文档。达到调用方能够正确使用它们的程度即可。
不要重复 TypeScript 类型,也不要为每个字段添加样板文档。记录原始声明,而不是重新导出的声明。
不要使用 @inheritDoc 或 @inherit。
对缺陷、框架要求的抛出行为和临时未实现的路径使用 @throws。
将内部预期失败记录为返回值。传输失败通道遵循后端技能的规定。
对每个已弃用的符号或路径使用语言提供的弃用指令。 只有在不存在指令时,才使用单行注释。为每项弃用创建一个 Linear issue。 说明移除条件、保留该路径的原因,以及如何通过验证确认其已移除。
工作区边界
应用放在 apps/,共享代码放在 packages/。
工作区依赖方向为 packages → ∅ 和 apps → packages。应用之间不得相互导入。
Core 导出共享类型、模式、常量和通用逻辑。
本地依赖使用 workspace:*。第三方版本和目录管理遵循 AGENTS.md。
通过项目脚本或任务图,在消费者之前构建共享依赖。
配置和资源
在启动时或最早的边界处解析环境变量/配置,转换为带类型的配置,并在适当时使用带品牌标记/已脱敏的值。将已知的配置失败作为带标签的错误值返回。组合根应报告安全的启动消息并终止,而不是将无效配置视为内部缺陷。
不要在应用中到处读取 process.env。缺失或无效的配置属于启动失败,应提供有用且安全的上下文。
除真正的入口点/引导文件外,避免顶层副作用。模块在导入时不得启动服务、打开连接、读取环境变量、注册处理程序或执行 I/O。
资源的创建和清理应明确,并由引导/命令式外壳代码负责;使用 Effect 时,则由 Effect 层负责。
避免可变单例/全局状态。常量和纯查找表没有问题。如果框架/运行时要求使用单例,应将其隔离在边界处。
将 Clock / Random 服务注入依赖型模块。纯领域函数可以接收显式的 now / 随机值。
Agent 快速检查清单
开始编码前:
- 阅读有关错误、模式、测试、适配器、遥测和模块布局的现有约定。
- 将每项变更涉及的内容归类为领域模块、应用服务模块、适配器模块或组合根接线。
- 优先复用现有领域模块、应用服务和适配器,再考虑创建新的。
- 将 Effect 依赖定义为范围精简、由应用持有的端口;将原始外部类型留在适配器或组合根中。
- 在边界处解析输入,并在内部使用领域类型。
- 在核心/应用逻辑中避免使用原始 DTO、原始 ID、可空对象集合和
Partial<T>。 - 对新增的预期失败,优先使用带类型的错误值。
- 保留现有的可观测性/错误处理机制。
- 通过公共接口和真实接缝进行测试。
- 实际可行时,使用
fast-check任意值生成测试数据。 - 当调用方需要了解类型无法体现的信息时,为导出的契约编写文档。
- 仅当通过适配器/应用服务复用审查发现了长期架构边界、共享模式、提供方策略或有意例外时,才添加 ADR。