---
title: "结构化错误"
description: "创建能够解释发生原因和解决方法的错误。为人类和 AI 代理添加包含 why、fix 和 link 字段的可操作上下文。"
canonical_url: "https://evlog.zhcndoc.com/learn/structured-errors"
---
# 结构化错误

> 创建能够解释发生原因和解决方法的错误。为人类和 AI 代理添加包含 why、fix 和 link 字段的可操作上下文。

evlog 提供了 `createError()` 函数，用于创建带有丰富、可操作上下文的错误。

<prompt :actions="["copy","cursor","claude"]" description="在我的应用中使用结构化错误" icon="i-lucide-shield-alert">

在我的整个应用中使用包含 code / why / fix / link 字段的结构化错误。

- 将普通的 `throw new Error(...)` 调用替换为 createError({ code, message, status, why, fix, link })
- 将 `code` 作为稳定、机器可读的标识符（例如 `'PAYMENT_DECLINED'`、`'auth/invalid-token'`），以便客户端和仪表盘可以据此分支处理
- 将 `message` 用于描述发生了什么，`why` 用于技术原因，`fix` 用于可操作的解决方案，`link` 用于文档
- 为 API 路由设置合适的 HTTP `status`（400 / 401 / 402 / 403 / 404 / 422 / 500）
- 对于仅内部使用的上下文，传入 `internal: { ... }`（会被记录，但绝不会在 HTTP 响应中返回）
- 在客户端，使用 parseError(err) 从任何抛出的错误中提取 { message, status, code, why, fix, link }
- 基于 `parseError(err).code === 'PAYMENT_DECLINED'` 进行分支，而不是解析面向用户的消息
- 在 toast/UI 中渲染 `why` 和 `fix`，让用户获得可操作的反馈

文档：[https://www.evlog.dev/learn/structured-errors](https://www.evlog.dev/learn/structured-errors)

</prompt>

## 为什么要使用结构化错误？

<structured-error-context>



</structured-error-context>

传统错误通常没有帮助：

```typescript [services/checkout.ts]
// Unhelpful error
throw new Error('Payment failed')
```

这只能告诉你*发生了什么*，而不能告诉你*为什么发生*或*如何修复*。

结构化错误提供了上下文信息：

<code-group>

```typescript [services/checkout.ts]
import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: '支付失败',
  status: 402,
  why: '发卡行拒绝（余额不足）',
  fix: '尝试其他支付方式或联系你的银行',
  link: 'https://docs.example.com/payments/declined',
})
```

```json [响应]
{
  "statusCode": 402,
  "message": "支付失败",
  "data": {
    "code": "PAYMENT_DECLINED",
    "why": "发卡行拒绝（余额不足）",
    "fix": "尝试其他支付方式或联系你的银行",
    "link": "https://docs.example.com/payments/declined"
  }
}
```

</code-group>

## 说明错误为何发生以及如何修复

<table>
<thead>
  <tr>
    <th>
      字段
    </th>
    
    <th>
      必需
    </th>
    
    <th>
      描述
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        message
      </code>
    </td>
    
    <td>
      是
    </td>
    
    <td>
      发生了什么（向用户显示）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        code
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      用于客户端分支处理的稳定、机器可读标识符（例如 <code>
        'PAYMENT_DECLINED'
      </code>
      
      ）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        status
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      HTTP 状态码（默认值：500）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        why
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      技术原因（用于调试）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        fix
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      可操作的解决方案
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        link
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      文档 URL
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cause
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      原始错误（用于错误链）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        data
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      返回给客户端的额外负载（见下文）
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        internal
      </code>
    </td>
    
    <td>
      否
    </td>
    
    <td>
      仅后端使用的上下文（见下文）
    </td>
  </tr>
</tbody>
</table>

## 面向客户端的额外负载（`data`）

`why`、`fix` 和 `link` 是供人阅读的字符串。当客户端需要基于某些值进行分支处理时，例如订单 id、重试截止时间或无效字段列表，请将它们作为 `data` 传入：

```typescript
throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Payment could not be completed',
  status: 402,
  fix: 'Try another payment method',
  data: {
    orderId: 'ord_8x2k',
    retryAfter: 30,
  },
})
```

该负载会被合并到响应正文的 `data` 对象中，与 `code`、`why`、`fix` 和 `link` 并列，因此客户端可以将它作为一个对象读取：

```typescript
const { data } = parseError(error)
if (typeof data?.retryAfter === 'number') scheduleRetry(data.retryAfter)
```

发生冲突时，这四个指导字段具有更高优先级，因此 `data: { code: 'x' }` 永远不会覆盖你传入的 `code`。这是 h3 的 `createError` 使用的同一个字段，evlog 的 Nitro 错误处理器也会为 h3 错误转发该字段。任何客户端绝不能看到的内容都应放入 `internal`。

## 仅后端使用的上下文（`internal`）

当你需要为日志、排水或支持工具提供额外字段时，请使用 `internal`，但**绝不能**在 API 响应或客户端的 `parseError()` 中暴露这些字段。

```typescript
throw createError({
  message: '无法完成付款',
  status: 402,
  why: '您的卡被拒绝了',
  fix: '请尝试其他支付方式',
  internal: {
    correlationId: 'pay_8x2k',
    processorCode: 'insufficient_funds',
    rawIssuerResponse: '…', // 不会发送给客户端
  },
})
```

- **HTTP 响应**（Nuxt/Nitro 错误处理器、Next.js、SvelteKit 等）和 **toJSON()** 会省略 `internal`。
- **parseError()** 不会在 UI 中暴露 `internal`；但在服务端调试时，抛出的错误可能在 `raw` 中仍携带它。
- **广泛事件**：当框架记录错误时（例如 `log.error(err)` 或自动捕获抛出的 `EvlogError`），发出的有效载荷会包含 `error.internal`。

在调试器中，有效载荷可能显示在符号键下；在代码中，始终使用 **error.internal**。

## 抛出能够自我解释的错误

### 简单错误

<code-group>

```typescript [server/api/users/[id].get.ts]
import { createError } from 'evlog'

throw createError({
  message: '用户未找到',
  status: 404,
})
```

```json [响应]
{
  "statusCode": 404,
  "message": "用户未找到"
}
```

</code-group>

### 带有完整上下文的错误

<code-group>

```typescript [services/checkout.ts]
import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: '支付失败',
  status: 402,
  why: '卡片被发卡行拒绝',
  fix: '请尝试其他支付方式',
  link: 'https://docs.example.com/payments/declined',
})
```

```json [响应]
{
  "statusCode": 402,
  "message": "支付失败",
  "data": {
    "code": "PAYMENT_DECLINED",
    "why": "卡片被发卡行拒绝",
    "fix": "请尝试其他支付方式",
    "link": "https://docs.example.com/payments/declined"
  }
}
```

</code-group>

### 错误链

在保留原始错误的同时包装底层错误：

```typescript [services/checkout.ts]
import { createError } from 'evlog'

try {
  await stripe.charges.create(charge)
} catch (err) {
  throw createError({
    message: '支付处理失败',
    status: 500,
    why: 'Stripe API 返回了一个错误',
    cause: err, // 保留原始错误
  })
}
```

## 开发终端输出

在 `pretty: true` 中进行开发时（默认），evlog 会在终端中将失败的请求打印为宽事件。**error 块会先显示**，然后是请求上下文（`user`、`cart` 等）。结构化字段（`why`、`fix`、`link`）会显示在错误消息下方，并带有源位置和可选的代码片段。

<code-group>

```typescript [services/checkout.ts]
import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Card declined',
  status: 402,
  why: '发卡行拒绝了该扣款',
  fix: '请客户使用另一张卡',
  link: 'https://docs.example.com/payments/declined',
})
```

```bash [终端（pretty 开发）]
ERROR [checkout] POST /api/checkout 402 in 123ms
  ├─ error: 卡被拒绝
  │     at server/api/checkout.post.ts:42
  │   ❯ 42 ┃ throw createError({ code: 'PAYMENT_DECLINED', ... })
  │     Why: 发卡行拒绝了该扣款
  │     Fix: 请客户使用另一张卡
  │     More: https://docs.example.com/payments/declined
  │     stack (3 frames hidden in node_modules)
  ├─ user: id=1 plan=pro
  └─ cart: items=3 total=9999
```

</code-group>

颜色和树形连接线会在终端中渲染；上面的示例省略了 ANSI 以便于阅读。

### 选择 evlog 还是 Nitro 控制台输出

<table>
<thead>
  <tr>
    <th>
      目标
    </th>
    
    <th>
      配置
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      一个干净的信号——仅宽事件，不显示 Nitro <code>
        [request error]
      </code>
      
       覆盖层
    </td>
    
    <td>
      <code>
        dev: 'evlog'
      </code>
      
      （pretty 开发模式下的默认值）
    </td>
  </tr>
  
  <tr>
    <td>
      宽事件上下文 + Nitro 原生 Youch 堆栈（evlog 仅打印 Why/Fix）
    </td>
    
    <td>
      <code>
        dev: 'nitro'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      完整的 evlog 块 <strong>
        以及
      </strong>
      
       Nitro 覆盖层（调试）
    </td>
    
    <td>
      <code>
        dev: 'both'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      不使用 pretty 树（JSON 日志），但仍抑制 Nitro 覆盖层
    </td>
    
    <td>
      <code>
        pretty: false
      </code>
      
      、<code>
        dev: { frameworkOverlay: false }
      </code>
    </td>
  </tr>
</tbody>
</table>

更细粒度的控制位于 `dev.prettyError`（`snippet`、`stackDepth`、`compact`、`detail: 'full' | 'guidance'`）下。参见 [配置](https://evlog.zhcndoc.com/reference/configuration) 和 [Nuxt 集成](https://evlog.zhcndoc.com/integrate/frameworks/nuxt)。

## 基于 `code` 的分支处理

`code` 是你控制的稳定、机器可读标识符。将它与 `parseError()` 配合使用，这样客户端就可以根据逻辑进行分支处理，而无需解析面向用户的消息或依赖 HTTP 状态码。

<structured-error-branching>



</structured-error-branching>

```typescript [composables/useCheckout.ts]
import { parseError } from 'evlog'

try {
  await $fetch('/api/checkout', { method: 'POST', body: cart })
} catch (err) {
  const error = parseError(err)

  switch (error.code) {
    case 'PAYMENT_DECLINED':
      return showRetryWithDifferentCard()
    case 'CART_EXPIRED':
      return rebuildCart()
    default:
      return toast.add({ title: error.message, color: 'error' })
  }
}
```

`parseError()` 还会从 Node 风格错误（例如 `'ENOENT'`、`'ECONNRESET'`）以及任何具有字符串 `.code` 属性的 `Error` 实例中提取 `code`，因此现有系统错误也会通过同样的分支逻辑流转。

`code` 也会被复制到广泛事件中的 `error.code` 下，因此仪表盘和排水系统可以按 code 分组、告警和绘图，而无需解析自由文本消息。

## 向用户展示他们可以采取行动的信息

使用 `parseError()` 从捕获的错误中提取所有字段：

<code-group>

```typescript [composables/useCheckout.ts]
import { parseError } from 'evlog'

try {
  await $fetch('/api/checkout', { method: 'POST', body: cart })
} catch (err) {
  const error = parseError(err)

  console.log(error.message)  // "Payment failed"
  console.log(error.status)   // 402
  console.log(error.code)     // "PAYMENT_DECLINED"
  console.log(error.why)      // "Card declined"
  console.log(error.fix)      // "Try another card"
}
```

```typescript [composables/useCheckout.ts（Nuxt UI）]
import { parseError } from 'evlog'

const toast = useToast()

try {
  await $fetch('/api/checkout', { method: 'POST', body: cart })
} catch (err) {
  const error = parseError(err)

  toast.add({
    title: error.message,
    description: error.why,
    color: 'error',
    actions: error.link
      ? [{ label: '了解更多', onClick: () => window.open(error.link) }]
      : undefined,
  })
}
```

</code-group>

### 错误显示组件

创建一个可复用的错误显示组件：

```vue [components/ErrorAlert.vue]
<script setup lang="ts">
import { parseError } from 'evlog'

const { error } = defineProps<{
  error: unknown
}>()

const parsed = computed(() => parseError(error))
</script>

<template>
  <UAlert
    :title="parsed.message"
    :description="parsed.why"
    color="error"
    icon="i-lucide-alert-circle"
  >
    <template v-if="parsed.fix" #description>
      <p>{{ parsed.why }}</p>
      <p class="mt-2 font-medium">{{ parsed.fix }}</p>
    </template>
  </UAlert>
</template>
```

## 最佳实践

### 使用合适的状态码

<code-group>

```typescript [400 - 错误请求]
// 客户端错误 - 用户可以修复
throw createError({
  message: '无效的电子邮件格式',
  status: 400,
  fix: '请输入有效的电子邮件地址',
})
```

```typescript [401 - 未授权]
// 需要身份验证
throw createError({
  message: '请登录后继续',
  status: 401,
  fix: '登录您的账户',
  link: '/login',
})
```

```typescript [404 - 未找到]
// 资源未找到
throw createError({
  message: '未找到订单',
  status: 404,
})
```

```typescript [500 - 服务器错误]
// 服务器错误 - 不是用户的错
throw createError({
  message: '出现了问题',
  status: 500,
  why: '数据库连接超时',
  // 不提供 'fix' - 用户无法修复服务器错误
})
```

</code-group>

### 提供可操作的修复方案

<code-group>

```typescript [糟糕的示例]
// 无帮助的修复方案
throw createError({
  message: '上传失败',
  fix: '重试',
})
```

```typescript [良好的示例]
// 可操作的修复方案
throw createError({
  message: '上传失败',
  status: 413,
  why: '文件超过最大大小（10MB）',
  fix: '在上传前减小文件大小或压缩图片',
  link: '/docs/upload-limits',
})
```

</code-group>

## 错误目录

对于少量一次性错误以外的任何情况，都应将它们归入一个类型化的**目录**中。evlog 为此提供了两个基本工具：`defineError`（单个工厂）和 `defineErrorCatalog`（捆绑并添加前缀）。传输中的 `code` 会自动派生为 `${prefix}.${KEY}`，并且 `EvlogError` 实例会应用所有默认值。

### `defineErrorCatalog`

定义一组共享前缀的错误。约定：`UPPER_SNAKE_CASE` 键，`lower.dot.case` 前缀。

<code-group>

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

export const billingErrors = defineErrorCatalog('billing', {
  CART_EMPTY: {
    status: 400,
    message: '购物车为空',
  },
  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}`,
    fix: '充值后重试',
  },
})
```

```typescript [server/api/checkout.post.ts]
import { billingErrors } from '~/errors/billing'

export default defineEventHandler(async (event) => {
  const cart = await getCart(event)

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

  try {
    await stripe.charge(cart.total)
  }
  catch (e) {
    if (e.code === 'card_declined') throw billingErrors.PAYMENT_DECLINED({ cause: e })
    if (e.code === 'insufficient_funds') {
      throw billingErrors.INSUFFICIENT_FUNDS({
        available: e.balance,
        required: cart.total,
        cause: e,
      })
    }
    throw e
  }
})
```

</code-group>

每个条目都会变成一个带类型的工厂。目录元数据通过 `_codes` 和 `_prefix` 暴露以便检查（不可枚举，因此 `Object.keys(billingErrors)` 仍只返回条目名称）。

```typescript
billingErrors.PAYMENT_DECLINED.code   // 'billing.PAYMENT_DECLINED'
billingErrors.PAYMENT_DECLINED.status // 402
billingErrors._codes
// readonly [
//   'billing.CART_EMPTY',
//   'billing.PAYMENT_DECLINED',
//   'billing.INSUFFICIENT_FUNDS',
// ]
```

### 带类型参数的模板化消息

将 `message` 设置为函数，则参数在调用处会变为**必需且带类型**。

```typescript
const InvoiceOverdue = defineError('billing.INVOICE_OVERDUE', {
  status: 402,
  message: ({ daysOverdue }: { daysOverdue: number }) =>
    `发票逾期 ${daysOverdue} 天`,
  fix: '支付未结清发票以恢复服务',
})

throw InvoiceOverdue({ daysOverdue: 7 }) // 参数必需且经过类型检查
```

你仍然可以在调用处覆盖任何字段（`message`、`status`、`why`、`fix`、`link`、`internal`、`cause`）。目录默认值中的 `internal` 会与调用处值进行浅层合并（发生冲突时以调用处为准）。

### `defineError`：独立工厂

对于不适合目录的单次错误（或偏好每个错误一个文件的超大型仓库），可直接使用 `defineError`。其工厂形状与目录条目相同，但不进行前缀推导。

```typescript
// errors/FraudDetected.ts
import { defineError } from 'evlog'

export const FraudDetected = defineError('billing.FRAUD_DETECTED', {
  status: 403,
  message: '交易已被标记为待审核',
  why: 'ML fraud-score above threshold',
  fix: '请联系客服以验证您的身份',
})

throw FraudDetected()
```

### 到处都使用类型安全的代码（可选）

扩展 `RegisteredErrorCatalogs` 接口，使每个已注册的代码都能在 `createError({ code })`、`parseError(err).code` 以及代码库中任何其他带类型的 `code` 字段上获得自动补全。

<code-group>

```typescript [errors/types.ts]
import type { billingErrors } from './billing'
import type { authErrors }    from './auth'

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

```typescript [代码库中的任意位置]
// createError 会对已注册的 code 提供自动补全（也仍然接受临时字符串）
throw createError({
  code: 'billing.PAYMENT_DECLINED', // ← 自动补全，拼写错误会触发 TS 报错
  message: '银行卡被拒绝',
  status: 402,
})

// parseError().code 的类型是所有已注册 code 的联合类型
const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
//                ↑ 自动补全，重构安全
```

</code-group>

这完全属于类型层面：不需要运行时注册，也不需要初始化步骤。如果不需要，完全可以跳过；无论是否使用，运行时 API 都相同。

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

**打包提示。** 目录就是普通 TypeScript。发布 `@acme/errors-billing`，导出你的 `defineErrorCatalog(...)` 以及 `index.d.ts` 中的 `declare module 'evlog'` 扩展，类型就会沿着依赖链传递到每个使用者。每个共享包都拥有自己的前缀，不会有冲突。

</callout>

<callout color="primary" icon="i-lucide-arrow-right" to="https://evlog.zhcndoc.com/learn/catalogs">

**进一步了解。** 专门的 [目录页面](https://evlog.zhcndoc.com/learn/catalogs) 讲解了扩展路径（单文件 → 文件夹 → 功能 → npm 包）、完整的 npm 打包方案、组合模式、类型扩展深度解析以及常见陷阱。

</callout>

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

查看 [Next.js 指南](https://evlog.zhcndoc.com/integrate/frameworks/nextjs) 了解可运行的实现。

</callout>

## 下一步

- [宽事件](https://evlog.zhcndoc.com/learn/wide-events): 累积上下文并发出全面的事件
- [适配器](https://evlog.zhcndoc.com/integrate/adapters/overview): 将错误和事件发送到 Axiom、Sentry、PostHog 等
- [框架](https://evlog.zhcndoc.com/integrate/frameworks/overview): 按框架自动管理请求日志记录
- [快速开始](https://evlog.zhcndoc.com/start/quick-start): 查看所有 evlog API 的实际使用情况。

---

- [广泛事件](https://evlog.zhcndoc.com/learn/wide-events)
- [最佳实践](https://evlog.zhcndoc.com/reference/best-practices)


## Sitemap

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