evlog 提供了 createError() 函数,用于创建带有丰富、可操作上下文的错误。
在我的应用中使用结构化错误
为什么要使用结构化错误?
throw new Error("Payment failed")
err.message → "Payment failed"
err.status → undefined
err.why → undefined
err.fix → undefined Something went wrong.
Please try again.
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
}){ message, status, why, fix, link }
all fields available · safe by default传统错误通常没有帮助:
// 缺乏帮助的错误
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',
})
{
"statusCode": 402,
"message": "Payment failed",
"data": {
"code": "PAYMENT_DECLINED",
"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') |
status | 否 | HTTP 状态码(默认: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,
})
{
"statusCode": 404,
"message": "User not found"
}
带有完整上下文的错误
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',
})
{
"statusCode": 402,
"message": "Payment failed",
"data": {
"code": "PAYMENT_DECLINED",
"why": "Card declined by issuer",
"fix": "Try a different payment method",
"link": "https://docs.example.com/payments/declined"
}
}
错误链
在保留原始错误的同时包装底层错误:
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 状态码。
throw createError({
code: 'PAYMENT_DECLINED',
message: 'Payment failed',
status: 402,
why: 'Card declined by issuer',
fix: 'Try a different…',
}){
statusCode: 402,
message: 'Payment failed',
data: { code: 'PAYMENT_DECLINED' }
}{
code: 'PAYMENT_DECLINED',
message: 'Payment failed',
status: 402,
why: 'Card declined…',
fix: 'Try another…',
}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"
}
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,
})
}
错误显示组件
创建一个可复用的错误显示组件:
<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: 'Please log in to continue',
status: 401,
fix: 'Sign in to your account',
link: '/login',
})
// 资源未找到
throw createError({
message: 'Order not found',
status: 404,
})
// 服务器错误 - 不是用户的错
throw createError({
message: 'Something went wrong',
status: 500,
why: 'Database connection timeout',
// 不提供 'fix' - 用户无法修复服务器错误
})
提供可操作的修复方案
// 无帮助的修复方案
throw createError({
message: 'Upload failed',
fix: 'Try again',
})
// 可操作的修复方案
throw createError({
message: 'Upload failed',
status: 413,
why: 'File exceeds maximum size (10MB)',
fix: 'Reduce the file size or compress the image before uploading',
link: '/docs/upload-limits',
})
错误目录
对于超出少数零散错误的情况,请将它们归类到带类型的 目录 中。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',
},
})
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
}
})
每个条目都会变成一个带类型的工厂。目录元数据通过 _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 }) // 参数必需且经过类型检查
你仍然可以在调用处覆盖任何字段(message、status、why、fix、link、internal、cause)。目录默认值中的 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
}
}
// createError 会对已注册的 code 提供自动补全(也仍然接受临时字符串)
throw createError({
code: 'billing.PAYMENT_DECLINED', // ← 自动补全,拼写错误会触发 TS 报错
message: 'Card declined',
status: 402,
})
// parseError().code 的类型是所有已注册 code 的联合类型
const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
// ↑ 自动补全,重构安全
这完全是类型层面的——无需运行时注册,也无需初始化步骤。如果你不需要,可以完全跳过;运行时 API 无论如何都一样。
@acme/errors-billing,导出你的 defineErrorCatalog(...) 以及 index.d.ts 中的 declare module 'evlog' 扩展,类型就会沿着依赖链传递到每个使用者。每个共享包都拥有自己的前缀,不会有冲突。