Fundamentals
Error handling
Handle expected API errors and diagnose unexpected failures.
On this page
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() to inspect errors reported by the API runtime.
Define an error once, then assign it to a status in the contract:
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.
The implementation receives only the errors available to that route:
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:
{
"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.
This is the default client behavior:
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.
Set errorMode: 'throw' when the application already centralizes expected failures in an error boundary or interceptor:
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.
Operational errors share the structural APIError interface:
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.
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.
toAPIProblem() turns an APIError into JSON-safe data. It preserves codes and issue locations while normalizing paths and symbols.
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.