学习
从单个文件到多包 monorepo,扩展类型化错误与审计目录。约定、npm 打包方案、组合模式,以及类型增强的深入解析。

目录原语(defineError, defineErrorCatalog, defineAuditAction, defineAuditCatalog)与项目规模无关,始终相同。变化的是你如何组织它们。本页将深入讲解:约定、从单文件扩展到已发布的 npm 包的方案、组合模式,以及可选启用的类型增强。

在我的应用中设置类型化错误和审计目录

如果你还没有看过,请先阅读 结构化错误 → 错误目录审计 → defineAuditCatalog 以了解基础内容。本页默认你至少已经使用过这些原语一次。

约定

一套约定即可同时覆盖错误目录和审计目录。

约定示例
目录键UPPER_SNAKE_CASE(类似枚举,适合扩展到数百项)PAYMENT_DECLINEDINVOICE_REFUND
前缀lower.dot.case,可以是层级式的'billing''billing.payment''auth.session'
线上格式${prefix}.${KEY}(保留大小写)billing.PAYMENT_DECLINEDauth.INVALID_TOKEN
一个目录 =一个有界上下文、一个前缀、一个文件errors/billing.tsaudit/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}`,
  },
})

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
src/errors/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.paymentbilling.subscriptionauth.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
src/features/billing/errors/payment.ts
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.DECLINEDbilling.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

packages/errors-billing/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"]
}

源码 — 目录 + 增强放在同一个文件中

packages/errors-billing/src/index.ts
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 导入的消费者都会间接获得该增强——无需他们额外做任何设置。

使用

apps/api/src/init.ts
// 导入该包会同时激活运行时目录和类型增强。
import { billingErrors } from '@acme/errors-billing'
import { authErrors } from '@acme/errors-auth'

// 从中央位置重新导出,这样应用的其余部分只需要一个导入路径。
export { billingErrors, authErrors }
apps/api/src/routes/checkout.post.ts
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 升级路径传播给消费者——无需代码生成,无需迁移步骤。

组合模式

混用目录与独立工厂

defineErrordefineErrorCatalog 生成完全相同的调用点形状。将目录用于分组错误,将 defineError 用于一次性错误(例如不属于特定领域的横切关注点,如限流)。

src/errors/index.ts
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 })

每个领域从一个入口重新导出

如果某个功能把错误和审计一起发布,就给它一个单独的重新导出模块,这样调用点只需要导入一次。

src/features/billing/index.ts
export { billingErrors } from './errors/billing'
export { billingAudit } from './audit/billing'
server/api/refund.post.ts
import { billingErrors, billingAudit } from '~/features/billing'

if (!cart.items.length) throw billingErrors.CART_EMPTY()

log.audit(billingAudit.INVOICE_REFUND({ actor, target: { id: 'inv_889' } }))

在调用点覆盖目录默认值

每个条目的默认值(messagestatuswhyfixlinkinternal)都可以在每次调用时覆盖。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共享同一前缀的类型化审计操作集合。每个条目都接受 targetdescriptionseverityrequiresChangesrequiresReasonredactPaths
AuditCatalogEntrytype单个目录条目的元数据形状(AuditActionDefinition 的别名)。
AuditSeveritytype'low' | 'medium' | 'high' | 'critical'.
RegisteredErrorCatalogsinterface可扩展的错误目录注册表。
RegisteredAuditCatalogsinterface可扩展的审计目录注册表。
ErrorCodetype所有已注册错误代码的联合类型。
AuditActiontype所有已注册审计操作的联合类型。

所有内容都从主 evlog 入口导出。

下一步

  • 结构化错误:完整的 createError API 和 parseError 参考。
  • 审计 → 记录:所有审计发射 API(log.auditwithAudit 等)。
  • 框架:按请求自动管理的日志器和 HTTP 错误序列化。