Logging

Structured Errors

Create errors that explain why they occurred and how to fix them. Add actionable context with why, fix, and link fields for humans and AI agents.

evlog provides a createError() function that creates errors with rich, actionable context.

Use structured errors in my app

Why Structured Errors?

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

Traditional errors are often unhelpful:

server/api/checkout.post.ts
// 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',
})

Error Fields

FieldRequiredDescription
messageYesWhat happened (shown to users)
codeNoStable machine-readable identifier for client branching (e.g. 'PAYMENT_DECLINED')
statusNoHTTP status code (default: 500)
whyNoTechnical reason (for debugging)
fixNoActionable solution
linkNoDocumentation URL
causeNoOriginal error (for error chaining)
internalNoBackend-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() omit internal.
  • parseError() does not surface internal for UI; the thrown error may still carry it server-side on raw when debugging.
  • Wide events: when the framework records the error (e.g. log.error(err) or automatic capture on thrown EvlogError), the emitted payload includes error.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,
})

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',
})

Error Chaining

Wrap underlying errors while preserving the original:

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, // 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.

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

Error Display Component

Create a reusable error display:

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>

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',
})

Provide Actionable Fixes

// Unhelpful fix
throw createError({
  message: 'Upload failed',
  fix: 'Try again',
})

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',
  },
})

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

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.

Packaging tip. A catalog is regular TypeScript. Publish @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.
Going further. The dedicated Catalogs page covers the scaling story (single file → folder → feature → npm package), the full npm packaging recipe, composition patterns, the type-augmentation deep dive, and common pitfalls.
See the Next.js guide for a working implementation.

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