---
title: "Structured Errors"
description: "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."
canonical_url: "https://www.evlog.dev/learn/structured-errors"
---
# 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.

<prompt :actions="["copy","cursor","claude"]" description="Use structured errors in my app" icon="i-lucide-shield-alert">

Use structured errors with code / why / fix / link fields throughout my app.

- Replace plain `throw new Error(...)` calls with createError({ code, message, status, why, fix, link })
- Use `code` as a stable, machine-readable identifier (e.g. `'PAYMENT_DECLINED'`, `'auth/invalid-token'`) so clients and dashboards can branch on it
- Use `message` for what happened, `why` for the technical reason, `fix` for the actionable solution, and `link` for docs
- Set the appropriate HTTP `status` for API routes (400 / 401 / 402 / 403 / 404 / 422 / 500)
- For internal-only context, pass `internal: { ... }` (logged but never returned in HTTP responses)
- On the client, use parseError(err) to extract { message, status, code, why, fix, link } from any thrown error
- Branch on `parseError(err).code === 'PAYMENT_DECLINED'` rather than parsing user-facing messages
- Render `why` and `fix` in toasts/UI so users get actionable feedback

Docs: [https://www.evlog.dev/learn/structured-errors](https://www.evlog.dev/learn/structured-errors)

</prompt>

## Why Structured Errors?

<structured-error-context>



</structured-error-context>

Traditional errors are often unhelpful:

```typescript [services/checkout.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:

<code-group>

```typescript [services/checkout.ts]
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',
})
```

```json [Response]
{
  "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"
  }
}
```

</code-group>

## Say why it broke and how to fix it

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Required
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        message
      </code>
    </td>
    
    <td>
      Yes
    </td>
    
    <td>
      What happened (shown to users)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        code
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Stable machine-readable identifier for client branching (e.g. <code>
        'PAYMENT_DECLINED'
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        status
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      HTTP status code (default: 500)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        why
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Technical reason (for debugging)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        fix
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Actionable solution
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        link
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Documentation URL
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        cause
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Original error (for error chaining)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        data
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Extra payload returned to the client (see below)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        internal
      </code>
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Backend-only context (see below)
    </td>
  </tr>
</tbody>
</table>

## Extra payload for the client (`data`)

`why`, `fix`, and `link` are strings a human reads. When the client needs values it branches on, an order id, a retry deadline, a list of invalid fields, pass them as `data`:

```typescript
throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Payment could not be completed',
  status: 402,
  fix: 'Try another payment method',
  data: {
    orderId: 'ord_8x2k',
    retryAfter: 30,
  },
})
```

The payload is merged into the response body's `data` object next to `code`, `why`, `fix`, and `link`, so the client reads it as one object:

```typescript
const { data } = parseError(error)
if (typeof data?.retryAfter === 'number') scheduleRetry(data.retryAfter)
```

Those four guidance keys win on a collision, so `data: { code: 'x' }` never overwrites the `code` you passed. This is the same field h3's `createError` uses, and evlog's Nitro error handler forwards it for h3 errors too. Anything the client must never see goes in `internal` instead.

## 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.

```typescript
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**.

## Throw an error that explains itself

### Simple Error

<code-group>

```typescript [server/api/users/[id].get.ts]
import { createError } from 'evlog'

throw createError({
  message: 'User not found',
  status: 404,
})
```

```json [Response]
{
  "statusCode": 404,
  "message": "User not found"
}
```

</code-group>

### Error with Full Context

<code-group>

```typescript [services/checkout.ts]
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',
})
```

```json [Response]
{
  "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"
  }
}
```

</code-group>

### Error Chaining

Wrap underlying errors while preserving the original:

```typescript [services/checkout.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
  })
}
```

## How errors are logged

An `Error` you put in a log is serialized into the event, the same way `log.error(err)` serializes it. The server and the browser share this serializer.

- `log.error(err)`, and an `Error` anywhere inside a `log.*({ ... })` event, is logged with its `name`, `message`, `stack`, its own fields, and `cause`. Cycles are logged as `'[Circular]'`.
- On the server, `log.set()` serializes an `Error` the same way. The browser logger has no `log.set()`.
- `why`, `fix` and `link` are logged on any error that carries them, not only on `EvlogError`.
- An object (not an `Error`) that defines its own `toJSON` is kept as it is, so that method decides how it is written out.

In the browser, an `Error` in an event used to be logged as `{}`. Now it is logged with its fields:

```typescript [pages/checkout.vue]
const err = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })

log.error({ action: 'checkout', error: err })
```

```json [Logged event (trimmed)]
{
  "action": "checkout",
  "error": {
    "name": "Error",
    "message": "connection reset",
    "stack": "Error: connection reset\n    at ...",
    "code": "ECONNRESET"
  }
}
```

### Errors that wrap another error

h3 keeps the original error as `cause` and takes over its message and stack when you pass that error to `createError`. evlog logs the pair once: the original error, with the wrapper's `statusCode`, `statusMessage` and `data` added. The message, stack and `cause` are not repeated.

```typescript [server/api/checkout.post.ts]
import { createError } from 'h3'

try {
  await stripe.charges.create(charge)
} catch (err) {
  throw createError(err) // the logged error has statusCode 500
}
```

When you pass an options object instead, h3 keeps that object as `cause`. evlog drops it when every field in it is already on the error, so the logged error keeps `statusCode: 402` and has no `cause`:

```typescript [server/api/pay.post.ts]
throw createError({ statusCode: 402, message: 'Payment could not be completed' })
```

### Custom `toJSON`

A custom error can define its own `toJSON`. For an `Error`, evlog reads it only to decide which payload fields to leave out:

- If `toJSON` leaves out the `data` or `internal` key, that field is not logged.
- If `toJSON` keeps the key but changes its value (for example `data: '[REDACTED]'` or `data: undefined`), the raw value is still logged.

So do not use `toJSON` to redact. Use [Redaction](https://www.evlog.dev/learn/redaction) instead. `EvlogError` keeps `internal` in logs even though its `toJSON`, which shapes the HTTP response, leaves it out.

## Development terminal output

In development with `pretty: true` (the default), evlog prints failed requests as a wide event in the terminal. The **error block comes first**, then request context (`user`, `cart`, …). Structured fields (`why`, `fix`, `link`) appear under the error message with a source location and optional code snippet.

<code-group>

```typescript [services/checkout.ts]
import { createError } from 'evlog'

throw createError({
  code: 'PAYMENT_DECLINED',
  message: 'Card declined',
  status: 402,
  why: 'Issuer declined the charge',
  fix: 'Ask the customer to use another card',
  link: 'https://docs.example.com/payments/declined',
})
```

```bash [Terminal (pretty dev)]
ERROR [checkout] POST /api/checkout 402 in 123ms
  ├─ error: Card declined
  │     at server/api/checkout.post.ts:42
  │   ❯ 42 ┃ throw createError({ code: 'PAYMENT_DECLINED', ... })
  │     Why: Issuer declined the charge
  │     Fix: Ask the customer to use another card
  │     More: https://docs.example.com/payments/declined
  │     stack (3 frames hidden in node_modules)
  ├─ user: id=1 plan=pro
  └─ cart: items=3 total=9999
```

</code-group>

Colors and tree connectors render in the terminal; the example above omits ANSI for readability.

### Choosing evlog vs Nitro console output

<table>
<thead>
  <tr>
    <th>
      Goal
    </th>
    
    <th>
      Config
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      One clean signal, wide event only, no Nitro <code>
        [request error]
      </code>
      
       overlay
    </td>
    
    <td>
      <code>
        dev: 'evlog'
      </code>
      
       (default in pretty dev)
    </td>
  </tr>
  
  <tr>
    <td>
      Wide event context + Nitro's native Youch stack (evlog prints Why/Fix only)
    </td>
    
    <td>
      <code>
        dev: 'nitro'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Full evlog block <strong>
        and
      </strong>
      
       Nitro overlay (debug)
    </td>
    
    <td>
      <code>
        dev: 'both'
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      No pretty tree (JSON logs) but still suppress Nitro overlay
    </td>
    
    <td>
      <code>
        pretty: false
      </code>
      
      , <code>
        dev: { frameworkOverlay: false }
      </code>
    </td>
  </tr>
</tbody>
</table>

Fine-grained control lives under `dev.prettyError` (`snippet`, `stackDepth`, `compact`, `detail: 'full' | 'guidance'`). See [Configuration](https://www.evlog.dev/reference/configuration) and [Nuxt integration](https://www.evlog.dev/integrate/frameworks/nuxt).

## 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-branching>



</structured-error-branching>

```typescript [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.

## Show the user what they can act on

Use `parseError()` to extract all fields from caught errors:

<code-group>

```typescript [composables/useCheckout.ts]
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"
}
```

```typescript [composables/useCheckout.ts (Nuxt UI)]
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,
  })
}
```

</code-group>

### Error Display Component

Create a reusable error display:

```vue [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

<code-group>

```typescript [400 - Bad Request]
// Client error - user can fix
throw createError({
  message: 'Invalid email format',
  status: 400,
  fix: 'Please enter a valid email address',
})
```

```typescript [401 - Unauthorized]
// Authentication required
throw createError({
  message: 'Please log in to continue',
  status: 401,
  fix: 'Sign in to your account',
  link: '/login',
})
```

```typescript [404 - Not Found]
// Resource not found
throw createError({
  message: 'Order not found',
  status: 404,
})
```

```typescript [500 - Server Error]
// 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
})
```

</code-group>

### Provide Actionable Fixes

<code-group>

```typescript [Bad]
// Unhelpful fix
throw createError({
  message: 'Upload failed',
  fix: 'Try again',
})
```

```typescript [Good]
// 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',
})
```

</code-group>

## 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.

<code-group>

```typescript [errors/billing.ts]
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',
  },
})
```

```typescript [server/api/checkout.post.ts]
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
  }
})
```

</code-group>

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).

```typescript
billingErrors.PAYMENT_DECLINED.code   // 'billing.PAYMENT_DECLINED'
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.

```typescript
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.

```typescript
// 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.

<code-group>

```typescript [errors/types.ts]
import type { billingErrors } from './billing'
import type { authErrors }    from './auth'

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

```typescript [Anywhere in your codebase]
// 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
```

</code-group>

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.

<callout color="neutral" icon="i-lucide-package">

**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.

</callout>

<callout color="primary" icon="i-lucide-arrow-right" to="https://www.evlog.dev/learn/catalogs">

**Going further.** The dedicated [Catalogs page](https://www.evlog.dev/learn/catalogs) 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.

</callout>

<callout color="neutral" icon="i-lucide-code">

See the [Next.js guide](https://www.evlog.dev/integrate/frameworks/nextjs) for a working implementation.

</callout>

## Next Steps

- [Wide Events](https://www.evlog.dev/learn/wide-events): Accumulate context and emit comprehensive events
- [Adapters](https://www.evlog.dev/integrate/adapters/overview): Send errors and events to Axiom, Sentry, PostHog, and more
- [Frameworks](https://www.evlog.dev/integrate/frameworks/overview): Auto-managed request logging per framework
- [Quick Start](https://www.evlog.dev/start/quick-start): See all evlog APIs in action

---

- [Wide Events](https://www.evlog.dev/learn/wide-events)
- [Best Practices](https://www.evlog.dev/reference/best-practices)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
