---
title: "目录"
description: "将类型化错误和审计目录从一个文件扩展到 monorepo：约定、npm 打包方案和类型增强。"
canonical_url: "https://evlog.zhcndoc.com/learn/catalogs"
---
# 目录

> 将类型化错误和审计目录从一个文件扩展到 monorepo：约定、npm 打包方案和类型增强。

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

<prompt :actions="["copy","cursor","claude"]" description="在我的应用中设置类型化错误和审计目录" icon="i-lucide-book-open">

将错误和审计动作分组到类型化目录中，以消除魔法字符串，在任何地方都能对 `code` 获得自动补全，并将它们作为 npm 包在 monorepo 中发布。

- 对错误集合使用 `defineErrorCatalog(prefix, map)`，对审计集合使用 `defineAuditCatalog(prefix, map)`
- 对不适合目录的一次性工厂，使用 `defineError(code, options)` 和 `defineAuditAction(action, opts?)`
- 约定：键名使用 UPPER_SNAKE_CASE，前缀使用 lower.dot.case，线上的格式是 `${prefix}.${KEY}`（例如 `billing.PAYMENT_DECLINED`、`billing.INVOICE_REFUND`）
- 一个目录 = 一个有界上下文 = 一个前缀 = 一个文件（例如 `errors/billing.ts`、`audit/billing.ts`）
- 使用 `billingErrors.PAYMENT_DECLINED({ cause, internal })` 抛出错误，使用 `log.audit(billingAudit.INVOICE_REFUND({ actor, target }))` 记录审计
- 当参数是动态且必需时，使用模板化消息（`message: ({ id }) => \`User ${id} not found``）
- 目录默认值中的 `internal` 会与调用处的值做浅合并（以调用处为准）
- 添加可选的 `declare module 'evlog' { interface RegisteredErrorCatalogs { billing: typeof billingErrors } }` 增强，以便在所有地方为 `createError({ code })`、`parseError(err).code` 和 `throwError(code)` 提供自动补全
- 按分片扩展规模：单文件 → 按领域划分文件夹 → 子前缀（`billing.payment`）→ 每个有界上下文一个 npm 包（各自拥有自己的前缀，不可能冲突）
- 每个共享包都在 `src/index.ts` 中提供自己的 `declare module 'evlog'` 块，这样类型增强就会通过已发布的 `.d.ts` 传播给消费者
- 测试时请比较 `factory.code`，不要比较字符串字面量，这样重命名会成为 TS 错误，而不是悄无声息的破坏：`expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code)`
- 不要在调用处覆盖 `code`（目录定义了 code 的身份）；也不要在测试文件中放 `declare module` 块（它们会泄漏到主类型检查器中）

文档：[https://www.evlog.dev/learn/catalogs](https://www.evlog.dev/learn/catalogs)

</prompt>

<tip>

如果你还没有看过，请先阅读 [结构化错误 → 错误目录](https://evlog.zhcndoc.com/learn/structured-errors#error-catalogs) 和 [审计 → defineAuditCatalog](https://evlog.zhcndoc.com/use-cases/audit/recording#defineauditcatalog) 以了解基础内容。本页默认你至少已经使用过这些原语一次。

</tip>

## 约定

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

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      约定
    </th>
    
    <th>
      示例
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        目录键
      </strong>
    </td>
    
    <td>
      <code>
        UPPER_SNAKE_CASE
      </code>
      
      （类似枚举，适合扩展到数百项）
    </td>
    
    <td>
      <code>
        PAYMENT_DECLINED
      </code>
      
      、<code>
        INVOICE_REFUND
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        前缀
      </strong>
    </td>
    
    <td>
      <code>
        lower.dot.case
      </code>
      
      ，可以是层级式的
    </td>
    
    <td>
      <code>
        'billing'
      </code>
      
      、<code>
        'billing.payment'
      </code>
      
      、<code>
        'auth.session'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        线上格式
      </strong>
    </td>
    
    <td>
      <code>
        ${prefix}.${KEY}
      </code>
      
      （保留大小写）
    </td>
    
    <td>
      <code>
        billing.PAYMENT_DECLINED
      </code>
      
      、<code>
        auth.INVALID_TOKEN
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        一个目录 =
      </strong>
    </td>
    
    <td>
      一个有界上下文、一个前缀、一个文件
    </td>
    
    <td>
      <code>
        errors/billing.ts
      </code>
      
      、<code>
        audit/billing.ts
      </code>
    </td>
  </tr>
</tbody>
</table>

线上格式最终会出现在 HTTP 响应、广播事件、drain 和仪表盘中。请在所有服务中保持一致，这样某个服务里的 `code` 在另一个服务中也能被识别。

## 扩展故事

同一组原语无需改变 API，即可覆盖四种规模。

### 单文件：小型仓库

一个 `errors.ts`，一个 `audit.ts`。完成。

<code-group>

```typescript [src/errors.ts]
import { defineErrorCatalog } from 'evlog'

export const errors = defineErrorCatalog('app', {
  USER_NOT_FOUND: { status: 404, message: '未找到用户' },
  FORBIDDEN: { status: 403, message: '禁止访问' },
  VALIDATION_FAILED: {
    status: 400,
    message: ({ field }: { field: string }) => `无效的 ${field}`,
  },
})
```

```typescript [src/audit.ts]
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 },
})
```

</code-group>

### 一个文件夹，每个领域一个文件：中型仓库

按有界上下文分组。在 `src/errors/` 和 `src/audit/` 中，每个领域一个文件。`index.ts` 用于导出以便更方便地导入，并集中管理类型增强。

```text
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
```

```typescript [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.payment`、`billing.subscription`、`auth.session`）能在保持命名空间清晰的同时缩短键名。每个子域一个目录。

```text
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
```

```typescript [src/features/billing/errors/payment.ts]
import { defineErrorCatalog } from 'evlog'

export const billingPaymentErrors = defineErrorCatalog('billing.payment', {
  DECLINED: { status: 402, message: '银行卡被拒绝' },
  INSUFFICIENT_FUNDS: { status: 402, message: '余额不足' },
  EXPIRED_CARD: { status: 402, message: '银行卡已过期' },
  CVV_MISMATCH: { status: 402, message: 'CVV 不匹配' },
})
```

线上 code 变成 `billing.payment.DECLINED`、`billing.payment.INSUFFICIENT_FUNDS` 等。该约定可扩展到数百项且不会冲突。

### npm 包：monorepo

在 monorepo 中，每个有界上下文都可以作为自己的 npm 包发布。类型增强会通过已发布的 `.d.ts` 传播，因此消费者只需 `pnpm add @acme/errors-billing` 就能获得自动补全。

```text
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`

```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"]
}
```

### 源代码：目录与增强位于同一文件中

```typescript [packages/errors-billing/src/index.ts]
import { defineErrorCatalog } from 'evlog'

export const billingErrors = defineErrorCatalog('billing', {
  PAYMENT_DECLINED: {
    status: 402,
    message: '卡片被拒绝',
    why: '发卡行拒绝了这笔扣款',
    fix: '尝试其他支付方式',
    link: 'https://docs.example.com/errors/billing.payment_declined',
  },
  INSUFFICIENT_FUNDS: {
    status: 402,
    message: ({ available, required }: { available: number, required: number }) =>
      `余额不足：$${available}/$${required}`,
  },
  // ...
})

declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    billing: typeof billingErrors
  }
}
```

`declare module` 块位于源文件中，因此打包器会将其输出到 `dist/index.d.ts`。任何从 `@acme/errors-billing` 导入内容的消费者都会传递性地获得该增强，无需在其一侧进行额外设置。

### 使用

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

// 从中央位置重新导出，这样应用的其余部分只需要一个导入路径。
export { billingErrors, authErrors }
```

```typescript [apps/api/src/routes/checkout.post.ts]
import { billingErrors } from '~/init'

throw billingErrors.PAYMENT_DECLINED({ cause: stripeErr })
```

```typescript [应用中的任意位置 — 自动补全可用]
import { createError, parseError } from 'evlog'

throw createError({
  code: 'billing.PAYMENT_DECLINED', // ← 来自已注册目录的自动补全
  message: '卡片被拒绝',
  status: 402,
})

const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
//                ↑ TypeScript 知道所有已注册 code 的联合类型
```

<callout color="neutral" icon="i-lucide-package">

**每个共享包拥有自己的前缀。** `@acme/errors-billing` 拥有 `billing.*`，`@acme/errors-auth` 拥有 `auth.*`。通过设计即可避免冲突。将目录升级到新的 minor 版本（添加条目）后，会通过常规的 semver 升级路径传播给消费者：无需代码生成，也无需迁移步骤。

</callout>

## 组合模式

### 混用目录与独立工厂

`defineError` 和 `defineErrorCatalog` 生成完全相同的调用点形状。将目录用于分组错误，将 `defineError` 用于一次性错误（例如不属于特定领域的横切关注点，如限流）。

```typescript [src/errors/index.ts]
import { defineError, defineErrorCatalog } from 'evlog'

export const billingErrors = defineErrorCatalog('billing', {
  PAYMENT_DECLINED: { status: 402, message: '卡片被拒绝' },
})

export const rateLimited = defineError('app.RATE_LIMITED', {
  status: 429,
  message: ({ retryAfter }: { retryAfter: number }) =>
    `限流：请在 ${retryAfter} 秒后重试`,
})

// 在调用点看起来完全一样：
throw billingErrors.PAYMENT_DECLINED()
throw rateLimited({ retryAfter: 30 })
```

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

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

```typescript [src/features/billing/index.ts]
export { billingErrors } from './errors/billing'
export { billingAudit } from './audit/billing'
```

```typescript [services/refund.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' } }))
```

### 在调用点覆盖目录默认值

每个条目的默认值（`message`、`status`、`why`、`fix`、`link`、`internal`）都可以在每次调用时覆盖。`internal` 会进行浅合并（冲突时以调用点为准）。

```typescript
// 目录默认值：
// message: '卡片被拒绝'
// internal: { category: 'gateway' }

throw billingErrors.PAYMENT_DECLINED({
  message: '此特定调用的自定义消息',
  internal: { stripeRef: 'ch_x', category: 'gateway-overridden' },
  cause: stripeErr,
})

// 生成的 EvlogError：
// - message: '此特定调用的自定义消息'（覆盖）
// - status: 402（目录默认值）
// - why: '发行方拒绝了该扣款'（目录默认值）
// - internal: { category: 'gateway-overridden', stripeRef: 'ch_x' }
```

## 类型增强：深入了解

可选启用的 `declare module 'evlog'` 块会让 `createError({ code })`、`parseError(err).code` 以及带类型的 `ErrorCode` / `AuditAction` 导出获得自动补全。

### 把扩展放在哪里

<table>
<thead>
  <tr>
    <th>
      仓库形态
    </th>
    
    <th>
      推荐位置
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      单文件（<code>
        src/errors.ts
      </code>
      
      ）
    </td>
    
    <td>
      放在同一文件底部
    </td>
  </tr>
  
  <tr>
    <td>
      文件夹（<code>
        src/errors/*.ts
      </code>
      
      ）
    </td>
    
    <td>
      放在 <code>
        src/errors/index.ts
      </code>
      
       中（集中式）或每个目录文件中（分散式）
    </td>
  </tr>
  
  <tr>
    <td>
      npm 包
    </td>
    
    <td>
      放在包的主入口 <code>
        src/index.ts
      </code>
      
       底部，这样它会随发布的 <code>
        .d.ts
      </code>
      
       一起输出
    </td>
  </tr>
  
  <tr>
    <td>
      Monorepo
    </td>
    
    <td>
      每个包一个扩展，无需中央注册表
    </td>
  </tr>
</tbody>
</table>

集中式和分散式都可行，因为 TypeScript 会自动合并跨文件的多个 `declare module 'evlog'` 块。

### 如何添加自定义领域

每个扩展键就是命名空间名称。共享前缀的多个目录可以合并到一个键中，也可以拆分：

```typescript [集中式 — 每个包一个键]
declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    billing: typeof billingErrors
  }
}
```

```typescript [分散式 — 每个子领域一个键]
declare module 'evlog' {
  interface RegisteredErrorCatalogs {
    'billing.payment': typeof billingPaymentErrors
    'billing.subscription': typeof billingSubscriptionErrors
    'billing.invoice': typeof billingInvoiceErrors
  }
}
```

`_codes` 字面量联合类型会生成实际的 `ErrorCode` 类型。键名本身是任意的，请根据你的结构选择合适的命名方式。

### 验证扩展

```typescript [代码库中的任意位置]
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）。

## 常见陷阱

<warning>

**不要把 declare module 块放在测试文件里。** 如果测试文件被包含进主 `tsconfig.json`，来自测试文件的扩展会泄漏到代码库其他部分的类型检查器中。把扩展放在目录源文件旁边，绝不要放在 `*.test.ts` 内。

</warning>

<warning>

**避免跨包前缀冲突。** 如果两个包扩展了同一个 `RegisteredErrorCatalogs` 键（比如都提供一个 `billing` 目录），TypeScript 会静默合并它们，而运行时会保留最后注册的工厂。约定：每个包一个前缀，不要重叠。

</warning>

<warning>

**绝不要在调用点覆盖 code。** 目录定义了 code 的身份，覆盖它会破坏仪表盘、告警以及根据 `err.code` 进行分支处理的消费者代码。工厂的调用点签名会特意从可覆盖字段中排除 `code`。

</warning>

<tip>

**测试中优先使用 factory.code，不要直接比较字符串。** 下面两种写法都有效；第一种在重命名后仍然成立（重构安全），第二种不行。

```typescript
expect(err.code).toBe(billingErrors.PAYMENT_DECLINED.code) // ✓ 重构安全
expect(err.code).toBe('billing.PAYMENT_DECLINED')          // ✗ 字符串字面量
```

</tip>

## API 参考

<table>
<thead>
  <tr>
    <th>
      符号
    </th>
    
    <th>
      类型
    </th>
    
    <th>
      用途
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        defineError(code, options)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      独立的单错误工厂。不进行前缀派生。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineErrorCatalog(prefix, map)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      共享同一前缀的类型化错误集合。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineAuditAction(action, opts?)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      独立的单操作审计工厂。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        defineAuditCatalog(prefix, map)
      </code>
    </td>
    
    <td>
      factory
    </td>
    
    <td>
      共享同一前缀的类型化审计操作集合。每个条目都接受 <code>
        target
      </code>
      
      、<code>
        description
      </code>
      
      、<code>
        severity
      </code>
      
      、<code>
        requiresChanges
      </code>
      
      、<code>
        requiresReason
      </code>
      
      、<code>
        redactPaths
      </code>
      
      。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuditCatalogEntry
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      单个目录条目的元数据形状（<code>
        AuditActionDefinition
      </code>
      
       的别名）。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuditSeverity
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      <code>
        'low' | 'medium' | 'high' | 'critical'
      </code>
      
      。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        RegisteredErrorCatalogs
      </code>
    </td>
    
    <td>
      interface
    </td>
    
    <td>
      可扩展的错误目录注册表。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        RegisteredAuditCatalogs
      </code>
    </td>
    
    <td>
      interface
    </td>
    
    <td>
      可扩展的审计目录注册表。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ErrorCode
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      所有已注册错误代码的联合类型。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        AuditAction
      </code>
    </td>
    
    <td>
      type
    </td>
    
    <td>
      所有已注册审计操作的联合类型。
    </td>
  </tr>
</tbody>
</table>

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

## 下一步

- [结构化错误](https://evlog.zhcndoc.com/learn/structured-errors)：完整的 `createError` API 和 `parseError` 参考。
- [审计 → 记录](https://evlog.zhcndoc.com/use-cases/audit/recording)：所有审计发射 API（`log.audit`、`withAudit` 等）。
- [框架](https://evlog.zhcndoc.com/integrate/frameworks/overview)：按请求自动管理的日志器和 HTTP 错误序列化。

---

- [结构化错误](https://evlog.zhcndoc.com/learn/structured-errors)
- [审计日志](https://evlog.zhcndoc.com/use-cases/audit/overview)


## Sitemap

See the full [sitemap](https://evlog.zhcndoc.com/sitemap.md) for all pages.
