Structured Errors
evlog provides a createError() function that creates errors with rich, actionable context.
Use structured errors in my app
Why Structured Errors?
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 defaultTraditional errors are often unhelpful:
// Unhelpful error
throw new Error('Payment failed')
This tells you what happened, but not why or how to fix it.
Structured errors provide context:
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"
}
}
Error Fields
| Field | Required | Description |
|---|---|---|
message | Yes | What happened (shown to users) |
code | No | Stable machine-readable identifier for client branching (e.g. 'PAYMENT_DECLINED') |
status | No | HTTP status code (default: 500) |
why | No | Technical reason (for debugging) |
fix | No | Actionable solution |
link | No | Documentation URL |
cause | No | Original error (for error chaining) |
internal | No | Backend-only context (see below) |
Backend-only context (internal)
Use internal when you need extra fields for logs, drains, or support tools, but must not expose them in API responses or to parseError() on the client.
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: '…', // never sent to the client
},
})
- HTTP responses (Nuxt/Nitro error handler, Next.js, SvelteKit, etc.) and
toJSON()omitinternal. parseError()does not surfaceinternalfor UI; the thrown error may still carry it server-side onrawwhen debugging.- Wide events: when the framework records the error (e.g.
log.error(err)or automatic capture on thrownEvlogError), the emitted payload includeserror.internal.
In debuggers, the payload may appear under a symbol key; in code, always use error.internal.
Basic Usage
Simple Error
import { createError } from 'evlog'
throw createError({
message: 'User not found',
status: 404,
})
{
"statusCode": 404,
"message": "User not found"
}
Error with Full Context
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"
}
}
Error Chaining
Wrap underlying errors while preserving the original:
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, // Original error preserved
})
}
Branching on code
code is a stable, machine-readable identifier you control. Pair it with parseError() so the client can branch on logic without parsing user-facing messages or coupling to HTTP status codes.
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() also surfaces code from Node-style errors (e.g. 'ENOENT', 'ECONNRESET') and any Error instance with a string .code property, so existing system errors flow through the same branch.
code is also copied onto wide events under error.code, so dashboards and drains can group, alert, and chart by code without parsing free-text messages.
Frontend Error Handling
Use parseError() to extract all fields from caught errors:
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: 'Learn more', onClick: () => window.open(error.link) }]
: undefined,
})
}
Error Display Component
Create a reusable error display:
<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>
Best Practices
Use Appropriate Status Codes
// Client error - user can fix
throw createError({
message: 'Invalid email format',
status: 400,
fix: 'Please enter a valid email address',
})
// Authentication required
throw createError({
message: 'Please log in to continue',
status: 401,
fix: 'Sign in to your account',
link: '/login',
})
// Resource not found
throw createError({
message: 'Order not found',
status: 404,
})
// Server error - not user's fault
throw createError({
message: 'Something went wrong',
status: 500,
why: 'Database connection timeout',
// No 'fix' - user can't fix server errors
})
Provide Actionable Fixes
// Unhelpful fix
throw createError({
message: 'Upload failed',
fix: 'Try again',
})
// Actionable fix
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',
})
Error Catalogs
For anything beyond a handful of one-off errors, group them in a typed catalog. evlog ships two primitives for this — defineError (single factory) and defineErrorCatalog (bundle prefixed). The wire code is auto-derived as ${prefix}.${KEY} and the EvlogError instance is built with all defaults applied.
defineErrorCatalog
Define a bundle of errors that share a prefix. Convention: UPPER_SNAKE_CASE keys, lower.dot.case prefix.
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
}
})
Each entry becomes a typed factory. Catalog metadata is exposed on _codes and _prefix for introspection (non-enumerable so Object.keys(billingErrors) still returns just the entry names).
billingErrors.PAYMENT_DECLINED.code // 'billing.PAYMENT_DECLINED' (literal type)
billingErrors.PAYMENT_DECLINED.status // 402
billingErrors._codes // readonly ['billing.CART_EMPTY', 'billing.PAYMENT_DECLINED', 'billing.INSUFFICIENT_FUNDS']
Templated messages with typed params
Set message to a function and the params become required and typed at the call site.
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 }) // params required and type-checked
You can still override any field at the call site (message, status, why, fix, link, internal, cause). Catalog defaults for internal are shallow-merged with call-site values (call-site wins on conflict).
defineError — standalone factories
For one-off errors that don't fit a catalog (or for very large repos that prefer one file per error), use defineError directly. Same factory shape as a catalog entry, no prefix derivation.
// 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()
Type-safe codes everywhere (opt-in)
Augment the RegisteredErrorCatalogs interface to make every registered code surface as autocomplete on createError({ code }), parseError(err).code, and any other typed code field across the codebase.
import type { billingErrors } from './billing'
import type { authErrors } from './auth'
declare module 'evlog' {
interface RegisteredErrorCatalogs {
billing: typeof billingErrors
auth: typeof authErrors
}
}
// createError autocompletes registered codes (and still accepts ad-hoc strings)
throw createError({
code: 'billing.PAYMENT_DECLINED', // ← autocomplete, TS error if typo
message: 'Card declined',
status: 402,
})
// parseError().code is typed as the union of all registered codes
const err = parseError(caught)
if (err.code === 'billing.PAYMENT_DECLINED') retry()
// ↑ autocomplete, refactor-safe
This is purely type-level — no runtime registration, no init step. Skip it entirely if you don't need it; the runtime API is identical either way.
@acme/errors-billing exporting your defineErrorCatalog(...) plus the declare module 'evlog' augmentation in its index.d.ts, and the typing flows transitively to every consumer that depends on it. Each shared package owns its prefix, no conflicts possible.Next Steps
- Wide Events: Accumulate context and emit comprehensive events
- Adapters: Send errors and events to Axiom, Sentry, PostHog, and more
- Frameworks: Auto-managed request logging per framework
- Quick Start: See all evlog APIs in action
Wide Events
Accumulate context over any unit of work and emit a single comprehensive event. Works for HTTP requests, scripts, background jobs, queue workers, and workflows.
Catalogs
Scale typed error and audit catalogs from a single file to multi-package monorepos. Conventions, npm packaging recipe, composition patterns, and the type-augmentation deep dive.