目录原语(defineError, defineErrorCatalog, defineAuditAction, defineAuditCatalog)与项目规模无关,始终相同。变化的是你如何组织它们。本页将深入讲解:约定、从单文件扩展到已发布的 npm 包的方案、组合模式,以及可选启用的类型增强。
在我的应用中设置类型化错误和审计目录
约定
一套约定即可同时覆盖错误目录和审计目录。
| 约定 | 示例 | |
|---|---|---|
| 目录键 | UPPER_SNAKE_CASE(类似枚举,适合扩展到数百项) | PAYMENT_DECLINED、INVOICE_REFUND |
| 前缀 | lower.dot.case,可以是层级式的 | 'billing'、'billing.payment'、'auth.session' |
| 线上格式 | ${prefix}.${KEY}(保留大小写) | billing.PAYMENT_DECLINED、auth.INVALID_TOKEN |
| 一个目录 = | 一个有界上下文、一个前缀、一个文件 | errors/billing.ts、audit/billing.ts |
线上格式最终会出现在 HTTP 响应、广播事件、drain 和仪表盘中。请在所有服务中保持一致,这样某个服务里的 code 在另一个服务中也能被识别。
扩展故事
同一组原语无需改变 API,即可覆盖四种规模。
1 个文件 — 小型仓库
一个 errors.ts,一个 audit.ts。完成。
import { defineErrorCatalog } from 'evlog'
export const errors = defineErrorCatalog('app', {
USER_NOT_FOUND: { status: 404, message: 'User not found' },
FORBIDDEN: { status: 403, message: 'Forbidden' },
VALIDATION_FAILED: {
status: 400,
message: ({ field }: { field: string }) => `Invalid ${field}`,
},
})
import { defineAuditCatalog } from 'evlog'
export const audit = defineAuditCatalog('app', {
USER_LOGIN: { target: 'user', severity: 'medium' },
USER_DELETE: { target: 'user', severity: 'high', requiresChanges: true, requiresReason: true },
})
1 个文件夹,每个领域 1 个文件 — 中型仓库
按有界上下文分组。在 src/errors/ 和 src/audit/ 中,每个领域一个文件。index.ts 用于导出以便更方便地导入,并集中管理类型增强。
src/
├── errors/
│ ├── billing.ts → billingErrors(前缀: 'billing')
│ ├── auth.ts → authErrors (前缀: 'auth')
│ ├── user.ts → userErrors (前缀: 'user')
│ └── index.ts → 重新导出 + declare module
├── audit/
│ ├── billing.ts → billingAudit
│ ├── auth.ts → authAudit
│ └── index.ts
import type { authErrors } from './auth'
import type { billingErrors } from './billing'
import type { userErrors } from './user'
export { authErrors } from './auth'
export { billingErrors } from './billing'
export { userErrors } from './user'
declare module 'evlog' {
interface RegisteredErrorCatalogs {
auth: typeof authErrors
billing: typeof billingErrors
user: typeof userErrors
}
}
该增强完全是类型层面的:没有 init 步骤,也没有运行时注册。在应用中的任何地方只要导入一次 ~/errors,TypeScript 就会拾取合并后的类型。
子前缀 — 超大型仓库
层级式前缀(billing.payment、billing.subscription、auth.session)能在保持命名空间清晰的同时缩短键名。每个子域一个目录。
src/features/
├── billing/
│ └── errors/
│ ├── payment.ts → billingPaymentErrors(前缀: 'billing.payment')
│ ├── subscription.ts → billingSubscriptionErrors
│ └── invoice.ts → billingInvoiceErrors
├── auth/
│ └── errors/
│ ├── session.ts → authSessionErrors(前缀: 'auth.session')
│ ├── oauth.ts → authOAuthErrors
│ └── mfa.ts → authMfaErrors
import { defineErrorCatalog } from 'evlog'
export const billingPaymentErrors = defineErrorCatalog('billing.payment', {
DECLINED: { status: 402, message: 'Card declined' },
INSUFFICIENT_FUNDS: { status: 402, message: 'Insufficient funds' },
EXPIRED_CARD: { status: 402, message: 'Card expired' },
CVV_MISMATCH: { status: 402, message: 'CVV mismatch' },
})
线上 code 变成 billing.payment.DECLINED、billing.payment.INSUFFICIENT_FUNDS 等。该约定可扩展到数百项且不会冲突。
npm 包 — monorepo
在 monorepo 中,每个有界上下文都可以作为自己的 npm 包发布。类型增强会通过已发布的 .d.ts 传播,因此消费者只需 pnpm add @acme/errors-billing 就能获得自动补全。
acme-monorepo/
├── packages/
│ ├── errors-billing/ → @acme/errors-billing
│ │ └── src/index.ts
│ ├── errors-auth/ → @acme/errors-auth
│ │ └── src/index.ts
│ └── audit-billing/ → @acme/audit-billing
│ └── src/index.ts
└── apps/
├── api/ → 导入并重新导出这些目录
└── worker/
将目录作为 npm 包发布
目录本质上只是依赖 evlog 作为 peer 依赖的普通 TypeScript。下面是最小方案。
package.json
{
"name": "@acme/errors-billing",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"peerDependencies": {
"evlog": "^3.0.0"
},
"files": ["dist"]
}
源码 — 目录 + 增强放在同一个文件中
import { defineErrorCatalog } from 'evlog'
export const billingErrors = defineErrorCatalog('billing', {
PAYMENT_DECLINED: {
status: 402,
message: 'Card declined',
why: 'Issuer declined the charge',
fix: 'Try a different payment method',
link: 'https://docs.example.com/errors/billing.payment_declined',
},
INSUFFICIENT_FUNDS: {
status: 402,
message: ({ available, required }: { available: number, required: number }) =>
`Insufficient funds: $${available}/$${required}`,
},
// ...
})
declare module 'evlog' {
interface RegisteredErrorCatalogs {
billing: typeof billingErrors
}
}
declare module 块位于源文件中,因此打包器会把它输出到 dist/index.d.ts。任何从 @acme/errors-billing 导入的消费者都会间接获得该增强——无需他们额外做任何设置。
使用
// 导入该包会同时激活运行时目录和类型增强。
import { billingErrors } from '@acme/errors-billing'
import { authErrors } from '@acme/errors-auth'
// 从中央位置重新导出,这样应用的其余部分只需要一个导入路径。
export { billingErrors, authErrors }
import { billingErrors } from '~/init'
throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr })
import { createError, parseError } from 'evlog'
throw createError({
code: 'billing.PAYMENT_DECLINED', // ← 来自已注册目录的自动补全
message: 'Card declined',
status: 402,
})
const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
// ↑ TypeScript 知道所有已注册 code 的联合类型
@acme/errors-billing 拥有 billing.*,@acme/errors-auth 拥有 auth.*。按设计不可能冲突。将某个目录升级到新的次版本(新增条目)会通过常规 semver 升级路径传播给消费者——无需代码生成,无需迁移步骤。组合模式
混用目录与独立工厂
defineError 和 defineErrorCatalog 生成完全相同的调用点形状。将目录用于分组错误,将 defineError 用于一次性错误(例如不属于特定领域的横切关注点,如限流)。
import { defineError, defineErrorCatalog } from 'evlog'
export const billingErrors = defineErrorCatalog('billing', {
PAYMENT_DECLINED: { status: 402, message: 'Card declined' },
})
export const rateLimited = defineError('app.RATE_LIMITED', {
status: 429,
message: ({ retryAfter }: { retryAfter: number }) =>
`Rate limited: retry in ${retryAfter}s`,
})
// 在调用点看起来完全一样:
throw billingErrors.PAYMENT_DECLINED()
throw rateLimited({ retryAfter: 30 })
每个领域从一个入口重新导出
如果某个功能把错误和审计一起发布,就给它一个单独的重新导出模块,这样调用点只需要导入一次。
export { billingErrors } from './errors/billing'
export { billingAudit } from './audit/billing'
import { billingErrors, billingAudit } from '~/features/billing'
if (!cart.items.length) throw billingErrors.CART_EMPTY()
log.audit(billingAudit.INVOICE_REFUND({ actor, target: { id: 'inv_889' } }))
在调用点覆盖目录默认值
每个条目的默认值(message、status、why、fix、link、internal)都可以在每次调用时覆盖。internal 会进行浅合并(冲突时以调用点为准)。
// 目录默认值:
// message: 'Card declined'
// internal: { category: 'gateway' }
throw billingErrors.PAYMENT_DECLINED({
message: '此特定调用的自定义消息',
internal: { stripeRef: 'ch_x', category: 'gateway-overridden' },
cause: stripeErr,
})
// 生成的 EvlogError:
// - message: '此特定调用的自定义消息'(覆盖)
// - status: 402(目录默认值)
// - why: 'Issuer declined the charge'(目录默认值)
// - internal: { category: 'gateway-overridden', stripeRef: 'ch_x' }
类型扩展 — 深入了解
可选启用的 declare module 'evlog' 块会让 createError({ code })、parseError(err).code 以及带类型的 ErrorCode / AuditAction 导出获得自动补全。
把扩展放在哪里
| 仓库形态 | 推荐位置 |
|---|---|
单文件(src/errors.ts) | 放在同一文件底部 |
文件夹(src/errors/*.ts) | 放在 src/errors/index.ts 中(集中式)或每个目录文件中(分散式) |
| npm 包 | 放在包的主入口 src/index.ts 底部,这样它会随发布的 .d.ts 一起输出 |
| Monorepo | 每个包一个扩展,无需中央注册表 |
集中式和分散式都可以——TypeScript 会自动合并跨文件的多个 declare module 'evlog' 块。
如何添加自定义领域
每个扩展键就是命名空间名称。共享前缀的多个目录可以合并到一个键中,也可以拆分:
declare module 'evlog' {
interface RegisteredErrorCatalogs {
billing: typeof billingErrors
}
}
declare module 'evlog' {
interface RegisteredErrorCatalogs {
'billing.payment': typeof billingPaymentErrors
'billing.subscription': typeof billingSubscriptionErrors
'billing.invoice': typeof billingInvoiceErrors
}
}
_codes 字面量联合类型才是生成实际 ErrorCode 类型的来源——这些键本身是任意的,按你的结构选择最合适的即可。
验证扩展
import type { ErrorCode, AuditAction } from 'evlog'
// 在 IDE 中悬停查看类型 — 应该显示所有已注册代码的联合类型。
type AllErrorCodes = ErrorCode
type AllAuditActions = AuditAction
// 编译期检查:
const validCode: ErrorCode = 'billing.PAYMENT_DECLINED' // 正确
const invalidCode: ErrorCode = 'billing.NOPE' // ← 如果目录已注册,这里会报 TS 错误
如果自动补全为空,要么还没有注册任何目录,要么扩展文件不在 TypeScript 程序中(检查 tsconfig.json 的 includes)。
常见陷阱
declare module 块放在测试文件里。 如果测试文件被包含进主 tsconfig.json,来自测试文件的扩展会泄漏到代码库其他部分的类型检查器中。把扩展放在目录源文件旁边,绝不要放在 *.test.ts 内。RegisteredErrorCatalogs 键(比如都提供一个 billing 目录),TypeScript 会静默合并它们,而运行时会保留最后注册的工厂。约定:每个包一个前缀,不要重叠。code。 目录定义了代码身份——覆盖它会破坏仪表盘、告警以及基于 err.code 分支的消费端代码。工厂的调用点签名刻意没有把 code 放进可覆盖字段。factory.code,不要直接比较字符串。 下面两种写法都有效;第一种在重命名后仍然成立(重构安全),第二种不行。expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code) // ✓ 重构安全
expect(err.code).toBe('billing.PAYMENT_DECLINED') // ✗ 字符串字面量
API 参考
| 符号 | 类型 | 用途 |
|---|---|---|
defineError(code, options) | factory | 独立的单错误工厂。不进行前缀派生。 |
defineErrorCatalog(prefix, map) | factory | 共享同一前缀的类型化错误集合。 |
defineAuditAction(action, opts?) | factory | 独立的单操作审计工厂。 |
defineAuditCatalog(prefix, map) | factory | 共享同一前缀的类型化审计操作集合。每个条目都接受 target、description、severity、requiresChanges、requiresReason、redactPaths。 |
AuditCatalogEntry | type | 单个目录条目的元数据形状(AuditActionDefinition 的别名)。 |
AuditSeverity | type | 'low' | 'medium' | 'high' | 'critical'. |
RegisteredErrorCatalogs | interface | 可扩展的错误目录注册表。 |
RegisteredAuditCatalogs | interface | 可扩展的审计目录注册表。 |
ErrorCode | type | 所有已注册错误代码的联合类型。 |
AuditAction | type | 所有已注册审计操作的联合类型。 |
所有内容都从主 evlog 入口导出。