学习

结构化错误

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

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

在我的应用中使用结构化错误

为什么要使用结构化错误?

error context·idle
vanilla·throw new Error()
throw new Error("Payment failed")
↓ caller catches
err.message  "Payment failed"
err.status   undefined
err.why      undefined
err.fix      undefined
error

Something went wrong.
Please try again.

structured·createError()
throw createError({
  message: "Payment failed", // what went wrong
  status: 402, // HTTP status
  why: "Card declined by issuer", // technical reason
  fix: "Try a different card", // actionable advice
  link: "/docs/payments/declined" // docs link
})
↓ parseError(err)
{ message, status, why, fix, link }
all fields available · safe by default
payment failed · 402

Card declined by issuer. Try a different card.

read more
1 field · user has to guess
5 fields · actionable end-to-end

传统错误通常没有帮助:

server/api/checkout.post.ts
// 缺乏帮助的错误
throw new Error('支付失败')

这只能告诉你发生了什么,而不能告诉你为什么发生如何修复

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

import { createError } from 'evlog'

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

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

字段必需描述
message发生了什么(向用户显示)
code用于客户端分支处理的稳定、机器可读标识符(例如 'PAYMENT_DECLINED'
statusHTTP 状态码(默认值:500)
why技术原因(用于调试)
fix可操作的解决方案
link文档 URL
cause原始错误(用于错误链)
data返回给客户端的额外负载(见下文)
internal仅后端使用的上下文(见下文)

面向客户端的额外负载(data

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

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 对象中,与 codewhyfixlink 并列,因此客户端可以将它作为一个对象读取:

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() 中暴露这些字段。

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

抛出能够自我解释的错误

简单错误

import { createError } from 'evlog'

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

带有完整上下文的错误

import { createError } from 'evlog'

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

错误链

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

server/api/checkout.post.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 块会先显示,然后是请求上下文(usercart 等)。结构化字段(whyfixlink)会显示在错误消息下方,并带有源位置和可选的代码片段。

import { createError } from 'evlog'

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

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

选择 evlog 还是 Nitro 控制台输出

目标配置
一个干净的信号——仅宽事件,不显示 Nitro [request error] 覆盖层dev: 'evlog'(pretty 开发模式下的默认值)
宽事件上下文 + Nitro 原生 Youch 堆栈(evlog 仅打印 Why/Fix)dev: 'nitro'
完整的 evlog 块 以及 Nitro 覆盖层(调试)dev: 'both'
不使用 pretty 树(JSON 日志),但仍抑制 Nitro 覆盖层pretty: falsedev: { frameworkOverlay: false }

更细粒度的控制位于 dev.prettyErrorsnippetstackDepthcompactdetail: 'full' | 'guidance')下。参见 配置Nuxt 集成

基于 code 的分支处理

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

structured error · server → client·SERVER
servercheckout.post.ts
throw createError({
  code:    'PAYMENT_DECLINED',
  message: 'Payment failed',
  status:  402,
  why:     'Card declined by issuer',
  fix:     'Try a different…',
})
awaiting throw
networkPOST /api/checkout
json envelope
{
  statusCode: 402,
  message: 'Payment failed',
  data: { code: 'PAYMENT_DECLINED' }
}
server
client
clientuseCheckout.ts
parseError(err)
{
  code:    'PAYMENT_DECLINED',
  message: 'Payment failed',
  status:  402,
  why:     'Card declined…',
  fix:     'Try another…',
}
switch (error.code)
case'PAYMENT_DECLINED':
showRetryWithDifferentCard()
case'CART_EXPIRED':
rebuildCart()
default:
toast.add({ ...error })
toast →Try a different payment method
stable code, no message parsing
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() 从捕获的错误中提取所有字段:

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

错误显示组件

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

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>

最佳实践

使用合适的状态码

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

提供可操作的修复方案

// 无帮助的修复方案
throw createError({
  message: '上传失败',
  fix: '重试',
})

错误目录

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

defineErrorCatalog

定义一组共享前缀的错误。约定:UPPER_SNAKE_CASE 键,lower.dot.case 前缀。

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: '充值后重试',
  },
})

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

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 设置为函数,则参数在调用处会变为必需且带类型

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

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

你仍然可以在调用处覆盖任何字段(messagestatuswhyfixlinkinternalcause)。目录默认值中的 internal 会与调用处值进行浅层合并(发生冲突时以调用处为准)。

defineError:独立工厂

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

// 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 字段上获得自动补全。

import type { billingErrors } from './billing'
import type { authErrors }    from './auth'

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

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

打包提示。 目录就是普通 TypeScript。发布 @acme/errors-billing,导出你的 defineErrorCatalog(...) 以及 index.d.ts 中的 declare module 'evlog' 扩展,类型就会沿着依赖链传递到每个使用者。每个共享包都拥有自己的前缀,不会有冲突。
进一步了解。 专门的 目录页面 讲解了扩展路径(单文件 → 文件夹 → 功能 → npm 包)、完整的 npm 打包方案、组合模式、类型扩展深度解析以及常见陷阱。
查看 Next.js 指南 了解可运行的实现。

下一步

  • 宽事件: 累积上下文并发出全面的事件
  • 适配器: 将错误和事件发送到 Axiom、Sentry、PostHog 等
  • 框架: 按框架自动管理请求日志记录
  • 快速开始: 查看所有 evlog API 的实际使用情况。