关于 coding-standards
面向 TypeScript 代码库及编码 Agent 的开发规范,指导新代码和重构中的架构、错误处理、解析、模块边界与测试。强调从现有代码和项目惯例出发,同时避免沿用不兼容的薄弱模式。
主要功能
- 01优先将预期错误作为值处理
- 02在边界解析输入并构造领域类型
- 03按职责划分模块与适配器
- 04使用类型表达状态和约束
- 05通过真实接口测试行为
- 06保护敏感数据并记录安全遥测
适用场景
- 01为 TypeScript 新功能制定实现约定
- 02指导局部重构的模块边界设计
- 03统一错误类型与输入解析方式
- 04规划基于真实接口的行为测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dmmulroy/ skills --skill coding-standards安装后在 Claude Code 中输入/coding-standards确认已加载。
详细介绍
这些标准说明了如何在此代码库中设计和编写 TypeScript 代码。它们尤其面向 Agent:添加模式、库、适配器或抽象之前,先检查现有代码;但所有新增和重构的行为都应遵循这些标准。只有在现有约定与这些标准兼容时,才遵循它们。
决策优先级
当规则相互冲突时,按以下顺序处理:
- 保证正确性、安全性和可调试性。
- 对所有新代码以及正在重构的完整行为应用这些标准。
- 遵循兼容的项目架构和约定。
- 在最近的边界处隔离不兼容的现有模式,而不是将它们复制到新代码中。
- 除非明确要求进行更广泛的迁移,否则避免更改无关的旧代码。
- 通过注释或 ADR 记录有意义的权衡。
核心原则
- 对于预期失败,优先使用作为值的错误,而不是
throw/ 被拒绝的 Promise。 - 解析,而非验证。尽早解析,并尽可能靠近组合根或应用根。不要只做验证,却丢弃已获取的信息。
- 在可行时,让非法状态无法表示。
- 优先使用正确性由构造保证的 API,而不是依赖约定的不变量。
- 当品牌类型/细化类型/领域类型能避免实际可能发生的错误时,优先使用它们,例如混淆标识符或单位、绕过解析,或构造无效值。
- 优先选择组合而非继承。
- 优先采用命令式外壳/函数式核心。
- 设计深入且内聚的模块,尽量减轻调用方负担。
- 通过真实接缝测试行为;避免模块模拟和由间谍驱动的测试。
- 让人类和 Agent 都能轻松找到代码。
适应现有代码库
在添加新模式或库之前,先检查代码库中对以下方面的现有选择:
- 错误处理
- 模式解析
- 依赖注入
- 测试
- 可观测性
- 适配器/服务
- 模块布局
对所有新代码以及正在重构的完整行为应用这些标准。不要仅仅为了保持一致而保留较弱的模式。保持无关的旧代码不变,并在最近的边界处转换不兼容的模式。
例如,如果现有代码使用异常式错误,不要为了一个无关的更改而重写整个系统。在新代码或重构代码中,将已知失败表示为带类型的值,然后在边界处将其转换为现有框架所需的结果。保留现有的日志记录、追踪、指标和错误报告钩子。
错误与失败
预期失败应作为值表示
每种已知失败模式都应作为自定义标记错误出现在返回类型中,即使直接调用方无法恢复也不例外。调用方必须处理该错误,或将其向上返回。在最外层边界,将其转换为有效结果,例如 HTTP 响应、CLI 退出代码、重试决策、死信或启动错误消息。
已知失败包括领域、解析、授权、集成、I/O、持久化、配置和工作流失败。
优先顺序:
- 如果代码库已经使用 Effect,则使用 Effect。
- 如果
better-result可用且适用,则使用它。 - 使用小型本地标记联合:
type Result<T, E extends Error> =
| { readonly _tag: "ok"; readonly value: T }
| { readonly _tag: "err"; readonly error: E };
优先使用:
Promise<Result<User, UserLookupError>>
而不是:
Promise<User> // rejects for ordinary lookup/storage failures
Promise 拒绝等同于抛出异常。在拥有该适配器的适配器内部捕获未分类的第三方拒绝,并将其转换为已知的标记错误,然后再让它越过适配器边界。只有缺陷才可以通过拒绝逸出应用代码。
缺陷可能导致抛出异常或 panic
仅当缺陷导致无法正确执行时才抛出异常或 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 式类型。
敏感数据、遥测与调试
优先对请求、作业、工作流、应用模块、适配器和外部调用进行端到端结构化跟踪。
跟踪/日志记录应包含安全字段,以便诊断失败:
- 领域 ID
- 操作名称
- 依赖项/提供方名称
- 状态标记
- 重试次数
- 类型化错误标记
- 安全摘要
不要在错误、跟踪记录、日志或快照中放入机密信息。
对令牌、API 密钥、密码、原始凭据和机密等敏感值使用 Redacted<T> 包装器。在 Effect 代码库中优先使用 Effect 的 Redacted.Redacted,否则使用本地共享的 Redacted<T> 包装器。
在边界处包装敏感值,仅在需要原始值时才解包,通常是在进行外部调用的适配器内部。
解析,而非验证
边界代码应将未知或结构较松散的输入转换为应用或领域类型,然后再将其传入内部代码。
仅当协议投影的形状或含义存在足够大的差异,值得单独定义时,才使用单独的协议投影。DTO 在描述中表示边界角色;绝不要在符号名称中使用 DTO 或 Dto。符号应按其实际协议或持久化含义命名,例如 CreateUserRequest、StripeCustomerResponse 或 UserRecord:
unknown -> CreateUserRequest -> CreateUserInput -> EmailAddress/UserId/etc.
否则,直接将输入解析为应用输入:
unknown -> CreateUserInput
不要在整个应用中传递由架构推断出的传输形状:
unknown -> z.infer<typeof CreateUserSchema>
使用能够保留含义的名称:
- 对不受信任或结构较松散的输入,使用
parseX(input): Result<X, ParseXError> - 对已类型化部分使用智能构造函数,使用
makeX(...)/createX(...) - 对真正的谓词,使用
isX(value): value is X - 尽量少用
assertX(...),主要用于测试/框架边界
如果函数返回的是精化后的值,避免使用 validateX。它解析了某些内容。
架构
将架构库用作边界解析器,不要将其作为临时验证器散布在核心逻辑中。
优先顺序:
- 如果仓库已有成熟的架构库,请使用该库
- 在 Effect 代码库中使用 Effect Schema
- 对通用辅助函数,优先兼容 Standard Schema
- 否则优先使用 Zod 4
- 对较小的领域类型,如果手写智能构造函数/解析器更清晰,则使用手写实现
架构解析在可行时应生成精化/领域类型和类型化自定义错误。
品牌类型与正确构造
在品牌类型/精化类型能够避免实际的误用或无效构造时使用它们,尤其适用于:
- ID:
UserId、OrgId、WorkflowId - 已解析的字符串:
EmailAddress、NonEmptyString、Url - 受约束的数字:
PositiveInt、Cents、Percentage - 单位:
Milliseconds、Bytes、UsdCents
通过解析器或智能构造器构造品牌值。如果存在领域类型,请勿传递原始字符串/数字。
需要某个值的函数中,避免使用可选值、null 或 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;
模块与抽象
领域模块、应用服务模块和适配器模块用于命名职责,而非规定文件夹、后缀或 TypeScript 结构。模块可以是函数、对象、类、文件或具有内聚公共接口的包。可在任何规模上采用这些角色;行为不需要时,不要强行创建三层。
包含应用策略或副作用的操作,其常规依赖与调用流程如下:
external input -> inbound Adapter -> Application Service -> Domain Module
|
+-> application-owned port
-> outbound Adapter -> external system
仅当操作是纯操作,不涉及授权、应用策略、持久化、外部调用或副作用顺序时,入站适配器才可以直接调用领域模块:
external input -> inbound Adapter -> Domain Module
组合根负责构造具体适配器,并将其提供给应用服务。依赖指向内部:领域模块既不了解服务,也不了解适配器;应用服务了解应用自有的端口契约,而不了解具体技术;适配器依赖这些契约,并在边界处进行转换。
选择角色
根据代码发生变化的原因对其进行分类:
- 业务含义、不变量、计算或合法状态转换发生变化:领域模块。
- 应用操作的策略、授权或副作用顺序发生变化:应用服务模块。
- 协议、框架、数据库、运行时或第三方 API 发生变化:适配器模块。
- 只有构造、配置或资源连接方式发生变化:组合根。
如果一个抽象有多个变化原因,就将其拆分。不要只为满足分类而拆分代码:纯操作可能只需要领域模块,而简单边界也可能直接调用应用服务,无需新增领域类型。
在任何代码库中应用这些角色
开发新功能或进行局部重构时:
- 从入口追踪一项调用方可见的操作,直到其中每个副作用。
- 将内在含义、不变量、计算和状态转换放入领域模块。
- 将应用策略和副作用顺序放入应用服务;将其依赖定义为精简的端口。
- 将每种协议或技术的转换放入入站或出站适配器。
- 在组合根将具体适配器连接到端口。
- 通过各角色的公共接口进行验证:领域结果、应用结果以及边界记录/响应。
在项目现有的布局和框架术语中落实这些职责。只有在功能所需的语义范围内,才迁移混合代码;否则,应在适配器边界封装旧约定,而不是强行进行大范围重写。
例如,在密码重置功能中:EmailAddress 和 ResetToken 是领域模块;PasswordReset 是应用服务;HTTP 路由是入站适配器;Postgres 和电子邮件提供商的实现是出站适配器;启动代码负责连接这些组件。
深层模块
深层模块通过凝聚且低负担的接口,隐藏大量行为、不变量、策略、顺序安排或转换逻辑。低负担不一定意味着函数少。
避免只转发调用、镜像表结构、重命名其他 API 或暴露实现步骤的浅层抽象。
使用删除测试:
- 如果删除模块后复杂性消失了,它可能只是无用的转发层
- 如果删除模块后复杂性分散到各个调用方,它可能确实发挥了作用
领域模块
领域模块是 OCaml 传统中的纯类型中心型抽象数据类型。它围绕一个主要领域类型或紧密相关的类型族展开,并负责定义值的含义以及哪些操作合法。
当代码中存在有意义的领域差异、不变量、计算、决策或生命周期时,使用领域模块。如果引入领域抽象既无法避免现实中的误用,也无法集中管理有意义的规则,就保留基本类型或局部纯函数。
领域模块应:
- 根据需要,将其类型、辅助类型、解析器、智能构造函数、组合函数、谓词、合法状态转换、领域投影、格式化函数和测试生成器集中放在一起
- 让解析器和构造函数返回经过约束的值,使调用方无法创建无效实例
- 将预期失败表示为精确的值
- 保持确定性,并独立于 I/O、框架、持久化、环境时间、随机性和可变全局状态
它可以基于已解析的领域值定义纯权限决策。它不应对调用方进行身份验证、收集授权上下文、在执行应用操作时强制实施权限、决定副作用顺序、查询存储、调用网络,或暴露传输层/持久化 DTO。调用方应使用其操作,而不是重新实现检查逻辑或通过类型断言为值添加标记。
示例:
// email-address.ts
/** A parsed, normalized email address. */
export type EmailAddress = Brand<string, "EmailAddress">;
/** Parse an email address from untrusted input. */
export function parse(input: string): Result<EmailAddress, InvalidEmailAddress>;
/** Render an email address as a string. */
export function toString(email: EmailAddress): string;
/** Compare two email addresses for equality. */
export function equals(left: EmailAddress, right: EmailAddress): boolean;
领域模块可酌情使用普通函数、不可变值类或静态风格的类。若使用类:
- 通过
parse/make/ 智能构造函数进行构造 - 确保无法构造无效实例
- 对调用方保持字段只读/不可变
- 确保方法围绕该值保持内聚
- 不要在领域值类中隐藏依赖项或 I/O
- 避免通过继承实现领域行为
应用服务模块
应用服务模块负责一个内聚的应用操作或能力,例如 PasswordReset、Invitations 或 SubscriptionLifecycle。它应用应用层策略,并通过范围精简、由应用层拥有的端口编排副作用,同时将内在业务规则委托给领域模块。
当某项操作必须协调授权、领域决策、持久化、外部调用、事务、消息、时间、ID 或遥测时,或者同一操作需要从多个入口点调用时,使用应用服务模块。如果不存在应用层策略或副作用编排,直接调用领域模块即可。
应用服务应:
- 接受并返回应用/领域类型,并使用精确的预期错误联合类型
- 定义该操作所需的最小且有意义的端口
- 显式接收端口、配置、时钟、随机性及类似能力
- 负责决定发生哪些副作用、适用何种策略以及执行顺序
- 独立于 HTTP、CLI、队列、ORM、供应商 SDK 和运行时类型
它不应解析协议封装、渲染响应、执行 SQL、转换供应商 DTO,或重复领域模块的不变量。对于依赖注入类,优先采用构造函数注入;在 Effect 代码库中,使用 services/tags/layers。避免在每次调用时都传入依赖项集合。
方法数量没有任意上限。如果方法代表互不相关的能力、因不同原因而变化,或需要互不相关的依赖项,就拆分这些方法。避免使用 Manager、Processor、Helper 或通用的 UserService 等含糊名称,除非项目已形成这种惯例。
适配器模块
适配器模块负责一个边界的转换和技术机制。每当应用程序代码跨越框架、协议、序列化、进程、持久化、运行时或第三方边界时,都应使用适配器模块。
有两个方向:
- 入站适配器解析外部请求/事件/命令,调用应用服务或上述可直接调用的纯领域模块,并将结果投影为外部协议。示例:HTTP 路由、GraphQL 解析器、CLI 命令、队列消费者。
- 出站适配器使用具体技术实现应用服务端口,并将原始记录、SDK 值和外部故障转换为应用程序/领域类型及类型化错误。示例:Postgres 存储、Stripe 客户端、邮件发送器、系统时钟。
适配器应负责其边界的架构/DTO 转换、框架生命周期、外部错误分类和安全诊断。只有当短暂的技术故障可以安全重试,且重试不会改变端口的含义时,才可以重试。适配器不应决定业务资格、授权策略、合法状态转换或应用程序操作的顺序。原始外部类型应留在适配器或组合根中。
端口不是适配器。端口是由应用程序定义的契约,用于说明操作需要什么;出站适配器则是该契约的一种可替换实现。不要添加仅将相同结构转发给另一个内部模块、却未隐藏实际转换或机制的适配器。
组合根
组合根负责解析环境和配置、获取资源、构建具体适配器,并将它们注入应用服务。框架绑定和具体装配应放在此处;不要把组合根变成承载领域规则、应用策略或可复用边界转换的地方。
应用程序定义的端口与适配器复用
在需要端口的应用服务旁边定义端口,并使用应用程序自身的语言,而不是提供方的语言。依赖操作所用的最小有意义能力;具体适配器可以更宽泛,但应保持内聚。端口的输入、输出和错误必须是应用程序/领域类型,而不是原始行、SDK 对象或框架值。
由于 TypeScript 采用结构化类型,这种方式行之有效:
type UsersForPasswordReset = {
findActiveByEmail(email: EmailAddress): Promise<Result<ActiveUser, UserLookupError>>;
};
export class PasswordReset {
constructor(private readonly users: UsersForPasswordReset) {}
}
更宽泛的适配器也可以满足这一要求:
export class PostgresUsers {
findActiveByEmail(...) { ... }
findById(...) { ... }
updateProfile(...) { ... }
}
这样既能避免庞大的仓库,也能避免适配器泛滥到每个适配器都只有一个方法。
适配器复用审查
创建新的适配器或服务之前,Agent 必须审查现有适配器/服务。
按以下优先顺序处理:
- 通过较窄的依赖类型直接复用现有适配器。
- 如果新方法符合现有内聚能力,且出于相同原因而变更,则扩展现有适配器。
- 只有在复用/扩展会造成不良耦合或意外接口时,才创建新适配器。
常规的功能级适配器或应用服务无需 ADR。当新模块引入持久的架构边界、共享模式、提供方策略,或有意偏离这些标准时,应创建 ADR。ADR 应说明:
- 检查过哪些现有适配器或应用服务
- 为什么不适合复用或扩展
- 为什么新的边界或模式是一项独立的内聚能力
仓库与持久化
默认情况下,应避免每张表都配一个仓库。
当类仓库适配器代表一项内聚的领域持久化能力时,可以使用。它们应公开有意义的领域操作,并返回经过解析的领域类型/类型化错误,而不是原始行和 ORM 错误。
将原始数据库行和 ORM 模型视为基础设施 DTO。在应用程序/核心逻辑处理它们之前,先进行解析。SQL/ORM 细节应留在基础设施适配器或持久化模块中。
函数式核心、命令式外壳与入口点
领域模块构成功能核心。应用服务模块和适配器模块构成命令式外壳,但只有适配器包含特定于技术的关注点。这样,同一应用操作便可在 REST、CLI、GraphQL、工作进程及其他入口点中重复使用。
功能核心包含领域解析器、不变量、状态转换、计算、组合器和决策函数。它避免 I/O、隐藏依赖、隐式时间或随机性、抛出预期失败,以及特定于框架的关注点。
命令式外壳有两项不同的职责:
- 应用服务通过显式端口应用应用策略并按顺序执行副作用。
- 适配器解析或转换边界值、分类外部失败,并执行具体的 I/O。
入口点适配器应是精简的协议转换层。它们解析特定于协议的输入,调用领域模块解析器以获得精化后的值;当涉及应用策略或副作用时,调用应用服务;然后生成特定于协议的输出。如上所述,纯操作可以直接调用领域模块。不要在控制器、解析器、命令或处理程序中重复业务规则。
在身份验证和授权中,入站适配器负责验证边界凭据,并生成已解析的身份,例如 Principal、Session 或 CommandActor。领域模块可以针对已解析的领域值定义纯权限决策。应用服务负责收集所需上下文,并在执行应用操作时落实这些决策。适配器将缺失或无效的凭据以及被拒绝的操作转换为特定于协议的结果;它们不定义权限策略。
工作流、事务与幂等性
对于简单的单边界操作,使用普通函数调用或数据库事务。
当进度必须在进程丢失或消息重新投递后保留,或者操作需要较长延迟、补偿、可恢复性、计时器、人工审批、跨服务协调或多个事务边界时,使用 Saga 或持久化工作流。仅有短暂重试本身并不需要持久化工作流机制。
适配器负责安全、短暂的技术性重试。应用服务决定是否应再次尝试某个应用操作。持久化工作流负责需要在崩溃、延迟或消息重新投递后继续的重试。
不要在网络调用或长时间运行的操作期间保持数据库事务处于打开状态。
任何可能重试且可被外部观察到的变更或状态转换,都需要明确的幂等性策略:
- 幂等键
- 自然唯一约束
- 去重记录
- 状态机转换保护
- 事务性 outbox/inbox
重试不应依赖“副作用大概是安全的”这一假设。
测试
只要能在正常测试环境中通过真实的公共入口端到端地测试某项行为,且不依赖不可靠的第三方服务,也无需不合理的设置、运行时间或成本,就应添加端到端测试。当低层测试能为重要场景提供额外覆盖时,也应添加。
优先编写能够增强信心的测试:
- 尽可能通过真实的公共入口编写端到端测试
- 通过真实接缝编写集成测试
- 为纯 Domain Module 编写有针对性或基于属性的测试
- 只有在测试有意义的行为而非实现细节时,才编写单元测试
模块模拟时,绝不要使用 vi.mock 或 jest.mock。应使用真实接缝:
- 通过构造函数注入的接口/类
- Effect 服务/层
- SQLite 等本地数据库替代方案
- 行为简单时使用内存适配器
- 必要时使用外部适配器的伪实现
优先编写断言可观察输入/输出行为的测试:
- 返回值/错误
- 持久化状态
- 发出的事件/消息
- 渲染的响应
- 在伪实现/本地适配器中记录的已发送邮件
除非交互本身是唯一可观察的行为,否则应避免依赖间谍的测试,例如 expect(sendEmail).toHaveBeenCalledWith(...)。
对于持久化行为,如果 SQL/架构/事务行为很重要,应优先使用基于 SQLite/本地数据库的测试,而不是手工编写的内存伪实现。
属性测试与任意值生成器
当属性比示例更清晰时,使用 fast-check,尤其适用于:
- 解析器/智能构造器
- 品牌类型/精化类型
- 状态机
- 序列化往返
- 规范化/幂等性
- 满足定律的组合子
使用任意值生成器来生成模拟/测试数据。优先在其支持的领域模块附近导出任意值生成器:
src/billing/
invoice-number.ts
invoice-number.test.ts
invoice-number.arbitrary.ts
测试不应绕过解析器、智能构造器或不变量。
TypeScript 风格与安全性
在可行的情况下,使用严格的 TypeScript 设置:
strict: truenoUncheckedIndexedAccess: trueexactOptionalPropertyTypes: truenoImplicitOverride: truenoFallthroughCasesInSwitch: true
优先使用不可变值:
type CreateUserInput = {
readonly email: EmailAddress;
readonly roles: ReadonlyArray<Role>;
};
在局部命令式外壳代码、对性能敏感的内部实现、构建器或适配器中,只要将可变操作隐藏在精确定义的接口之后,就可以接受使用可变状态。
类型断言、any 与非空断言
避免使用:
any- 非空断言(
!) - 使用
as Type进行类型断言
as const 可以使用。
在高度泛型的辅助函数、品牌类型内部实现、互操作边界或 TypeScript 无法表达相关不变量的组合子中,可以例外使用。
除 as const 以外的任何类型断言,都需要附上类似 Rust 的安全性注释:
// SAFETY: TypeScript cannot express the brand. parseEmailAddress checked the normalized string before branding. Callers cannot construct EmailAddress except through this parser.
return normalized as EmailAddress;
少数情况下使用 any,也需要针对性的 oxlint 忽略规则和理由:
// oxlint-disable-next-line no-explicit-any -- SAFETY: This helper preserves arbitrary function parameters; TypeScript cannot express this variadic constraint without any.
type Fn = (...args: any[]) => unknown;
不要使用 !。应通过分支判断、解析或精化来处理。
导入、导出与文件
优先直接从定义抽象的文件导入。默认避免使用 barrel 文件 / index.ts 再导出层。
对于领域模块,命名空间导入通常能保留模块结构:
import * as EmailAddress from "./email-address";
EmailAddress.parse(input);
对类和职责明确的共享辅助函数使用具名导入:
import { PasswordReset } from "./password-reset";
仅导入和导出类型时,使用 import type / export type。
只导出调用方应使用的内容。除非有意共享,否则不要导出内部辅助函数。不要仅为了测试而导出内部实现。
除非有充分的互操作理由,否则避免使用 TypeScript namespace。
避免使用含义模糊的文件名:
utils.ts
helpers.ts
common.ts
misc.ts
使用精准的名称:
email-address.ts
billing-period.ts
string-case.ts
array.ts
如果不存在更精准的归属模块,少量通用且无处不在的辅助函数/类型可以放在同一个明确命名的模块中。适合放入其中的内容包括:
casesHandledshouldNeverHappennotYetImplementedRedactedTags、ExtractTag和ExcludeTag- 项目既不使用 Effect 也不使用
better-result时常用的Result辅助函数 - 通用类型工具
只保留目标项目确实需要的辅助函数。领域策略和应用策略应由其所属模块管理。
不设任意的文件大小限制。相比单纯追求小文件,优先考虑内聚性和易发现性。当文件有多个互不相关的变更原因,或调用方必须理解互不相关的概念时,再拆分文件。
注释与 JSDoc
注释应解释不变量、权衡、不明显的领域规则和安全依据。避免使用注释复述显而易见的代码。
JavaScript 或 TypeScript 模块中的每个导出符号都必须带有 JSDoc。导出类的公共方法和属性也必须带有 JSDoc。私有代码及其他内部代码仅在复杂度需要时才要求文档。将文档写在原始声明上;重新导出时无需重复添加文档。
不要使用 @inheritDoc、@inherit 或类似的继承标签。请在每个符号或成员上明确写出所需文档。
使用标准 JSDoc 语法:
/**
* Parse an email address from untrusted input.
*
* @param input - The untrusted string to parse.
* @returns A parsed email address, or `InvalidEmailAddress` when the input is invalid.
*/
export function parse(input: string): Result<EmailAddress, InvalidEmailAddress>;
泛型:
/**
* Map the success value of a result.
*
* @template T - The original success type.
* @template U - The mapped success type.
* @template E - The error type.
* @param result - The result to map.
* @param fn - The function applied to the success value.
* @returns A result with the mapped success value, or the original error.
*/
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E>;
仅对无法恢复的缺陷、框架要求的行为或临时的 notYetImplemented 路径使用 @throws。不要将预期的类型化错误记录为抛出异常。
对于复杂的导出对象类型,在有帮助时为字段添加文档:
/** Input required to create a user. */
export type CreateUserInput = {
/** The actor creating the user. */
readonly actor: AdminUser;
/** The parsed email address for the new user. */
readonly email: EmailAddress;
};
配置与资源
在启动时或最早的边界处解析环境/配置,转换为带类型的配置,并在适当时使用带品牌/脱敏的值。将已知的配置失败作为带标签的错误值返回。组合根应报告安全的启动信息并终止,而不是将无效配置视为内部缺陷。
不要在整个应用中读取 process.env。缺失或无效的配置属于启动失败,应提供有用且安全的上下文。
除真正的入口点/引导文件外,避免顶层副作用。模块在导入时不应启动服务、打开连接、读取环境变量、注册处理程序或执行 I/O。
资源的创建和清理应明确进行,并由引导/命令式外壳代码负责;使用 Effect 时则由 Effect 层负责。
避免可变单例/全局状态。常量和纯查找表没有问题。如果框架/运行时要求使用单例,应将其隔离在边界处。
将 Clock / Random 服务注入依赖这些服务的模块。纯领域函数可以接收显式的 now / 随机值。
Agent 快速检查清单
编码前:
- 阅读现有的错误处理、架构、测试、适配器、遥测和模块布局惯例。
- 将每项变更涉及的内容归类为领域模块、应用服务模块、适配器模块或组合根装配。
- 创建新模块前,先复用现有的领域模块、应用服务和适配器。
- 将副作用依赖定义为范围明确、由应用拥有的端口;将原始外部类型保留在适配器或组合根中。
- 在边界处解析输入,并在内部使用领域类型。
- 避免在核心/应用逻辑中使用原始 DTO、原始 ID、可空字段集合和
Partial<T>。 - 对新增的预期失败,优先将类型化错误作为值处理。
- 保留现有的可观测性/错误处理机制。
- 通过公共接口和真实接缝进行测试。
- 实际可行时,使用
fast-check任意值生成器生成测试数据。 - 为导出的符号添加 JSDoc。
- 只有在适配器/应用服务复用审查中发现了长期存在的架构边界、共享模式、提供方策略或有意设置的例外时,才添加 ADR。