学习

结构化错误

创建能够解释发生原因和解决方法的错误。为人类和 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('Payment failed')

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

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

import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Payment failed',
  status: 402,
  why: 'Card declined by issuer (insufficient funds)',
  fix: 'Try a different payment method or contact your bank',
  link: 'https://docs.example.com/payments/declined',
})

错误字段

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

仅后端使用的上下文(internal

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

throw createError({
  message: 'Payment could not be completed',
  status: 402,
  why: 'Your card was declined',
  fix: 'Try another payment method',
  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: 'User not found',
  status: 404,
})

带有完整上下文的错误

import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Payment failed',
  status: 402,
  why: 'Card declined by issuer',
  fix: 'Try a different payment method',
  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: 'Payment processing failed',
    status: 500,
    why: 'Stripe API returned an error',
    cause: err, // 保留原始错误
  })
}

基于 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: 'Invalid email format',
  status: 400,
  fix: 'Please enter a valid email address',
})

提供可操作的修复方案

// 无帮助的修复方案
throw createError({
  message: 'Upload failed',
  fix: 'Try again',
})

错误目录

对于超出少数零散错误的情况,请将它们归类到带类型的 目录 中。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: 'Cart is empty',
  },
  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} available, $${required} required`,
    fix: 'Add funds and retry',
  },
})

每个条目都会变成一个带类型的工厂。目录元数据通过 _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 }) =>
    `Invoice overdue by ${daysOverdue} day(s)`,
  fix: 'Pay outstanding invoice to resume service',
})

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: 'Transaction flagged for review',
  why: 'ML fraud-score above threshold',
  fix: 'Contact support to verify your identity',
})

throw FraudDetected()

到处都使用类型安全的 code(可选)

扩展 RegisteredErrorCatalogs 接口,使每个已注册的 code 都能在 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 的实际使用情况