# Error handling

> Handle expected API errors and diagnose unexpected failures.

A missing user and a broken connection need different handling. Declare expected failures, such as `NOT_FOUND`, in the contract so callers can handle them by name. Unexpected failures reject the call; use [`isAPIError()`](#inspect-operational-failures) to inspect errors reported by the API runtime.

## Declare expected application failures

Define an error once, then assign it to a status in the contract:

```ts filename="src/api/contract.ts"
import { defineContract, defineErrors, response, route } from '@hulla/api'
import { z } from 'zod'

export const errors = defineErrors({
  UNAUTHORIZED: {
    message: 'Authentication required',
  },
  PROJECT_NOT_FOUND: {
    message: 'Project not found',
    data: z.object({ id: z.string() }),
  },
})

export const contract = defineContract({
  errors: {
    401: errors.UNAUTHORIZED,
    404: errors.PROJECT_NOT_FOUND,
  },
  routes: {
    project: route.get('/projects/:id', {
      params: z.object({ id: z.string() }),
      responses: {
        200: response.json(z.object({ id: z.string(), name: z.string() })),
      },
    }),
  },
})
```

The declaration key becomes the stable error `code`. `message` is the human-readable default, and `data` holds the typed details that vary per occurrence.

> **Error data crosses the boundary**
>
> Messages and data may reach the client. Do not include secrets, raw database
> errors, access tokens, or internal stack details.

## Return the error from a handler

The implementation receives only the errors available to that route:

```ts filename="src/api/implementation.ts"
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'

const projects = new Map([['docs', { id: 'docs', name: 'Documentation' }]])

export const implementation = defineServer(contract).implement({
  project: ({ params, errors, response }) => {
    const project = projects.get(params.id)

    if (!project) {
      return errors.PROJECT_NOT_FOUND({ data: { id: params.id } })
    }

    return response(200, project)
  },
})
```

Returning and throwing a declared error are both supported. Return it for ordinary branching; throw it when the failure must escape nested application code.

On HTTP, the missing project becomes concise JSON:

```json
{
  "code": "PROJECT_NOT_FOUND",
  "message": "Project not found",
  "data": { "id": "missing" }
}
```

Several named errors may share one status. Put them in an array under that status, then narrow by both `status` and `body.code` on the client.

## Handle declared errors as values

This is the default client behavior:

```ts filename="src/features/project-page.ts"
const result = await api.project({ params: { id } })

if (result.status === 200) {
  renderProject(result.body)
} else if (result.body.code === 'PROJECT_NOT_FOUND') {
  showMissingProject(result.body.data.id)
} else {
  showSignIn()
}
```

The client can see only statuses and error codes declared by its contract selection. Ordinary application control flow stays in the resolved result.

## Opt into exception control flow

Set `errorMode: 'throw'` when the application already centralizes expected failures in an error boundary or interceptor:

```ts filename="src/api/client.ts"
import { isDeclaredError } from '@hulla/api'

const api = createClient(contract, {
  transport,
  errorMode: 'throw',
})

try {
  await api.project({ params: { id: 'missing' } })
} catch (error) {
  if (!isDeclaredError(error)) throw error

  console.log(error.code, error.message, error.data)
}
```

In throw mode, the successful result union no longer includes declared errors. Transport and runtime failures still need their own handling.

## Inspect operational failures

Operational errors share the structural `APIError` interface:

```ts
type APIError = Error & {
  readonly code: string
  readonly issues: readonly APIErrorIssue[]
}
```

Each issue has a `message` and may include a path, vendor code, and boundary location such as `params`, `body`, `response`, or `output`.

Use `isAPIError()` instead of `instanceof`. A structural guard keeps working across duplicated packages and JavaScript realms.

```ts filename="src/api/report-error.ts"
import { isAPIError } from '@hulla/api/errors'

export function reportError(error: unknown) {
  if (!isAPIError(error)) throw error

  console.error(error.code)

  for (const issue of error.issues) {
    console.error(issue.location, issue.path, issue.message)
  }
}
```

The main operational classes answer different questions:

- `SchemaValidationError`: which Standard Schema check failed?
- `ContractError`: which HTTP contract location was invalid?
- `ClientResponseError`: why could the client not accept the transport response?
- `ServerImplementationError`: which handlers are missing or duplicated?
- `ServerRuntimeError`: which request/runtime failure maps to an HTTP status?
- `QueryTransportError`: which query value could not cross the URL boundary?

Invalid authoring arguments remain ordinary `TypeError` values. The structured interface is for failures an integration or application policy may need to inspect.

## Convert an operational error to a protocol problem

`toAPIProblem()` turns an `APIError` into JSON-safe data. It preserves codes and issue locations while normalizing paths and symbols.

```ts filename="src/api/problem.ts"
import { isAPIError, toAPIProblem } from '@hulla/api/errors'

export function toProblem(error: unknown) {
  if (!isAPIError(error)) throw error

  return toAPIProblem(error, {
    status: 400,
    title: 'Invalid request',
  })
}
```

Use that conversion at an application-owned protocol boundary. Do not turn every declared application error into an operational problem; they already have a typed contract representation.
