Skip to content

Error handling

Elysia provides statically analyzable error handling. This allows you to predict most possible errors from your server similar to Effect at home.

This is especially useful for Eden and OpenAPI Type Gen to generate types for your API.

This page covers the following topics:

We recommend visiting the error lifecycle or error handling tutorial before reading this page.

Returning an error allows TypeScript to statically analyze the return type while a thrown error doesn’t allow this.

At runtime, a returned Error is handled like a thrown one: it goes through your error hooks and produces the same response.

status() and problem() are different. Returning them sends an ordinary response without entering error handling.

problem() returns an RFC 9457 problem document with the application/problem+json content type. Elysia fills type and title for you:

import { Elysia, problem } from 'elysia'
new Elysia()
.get('/', () => problem(409, { detail: 'Username is taken' }))
HTTP 409
content-type: application/problem+json

{
  "type": "about:blank",
  "detail": "Username is taken",
  "status": 409,
  "title": "Conflict"
}

Both status() and problem() accept a status name in place of the number, such as problem('Conflict', { detail: 'Username is taken' }).

Use .onError() to handle every error of an instance, or the error property to handle errors of a single route:

import { Elysia } from 'elysia'
new Elysia()
.get('/', {
error({ error, status }) {
return status(503, 'Try again later')
}
}, () => {
throw new Error('Maintenance window')
})

Returning a value from the hook sends it as the response. Returning nothing passes the error to the next hook, then to Elysia’s default response.

Returning an Error sends it as if no hook handled it, at that error’s own status. An HTTPError sends its problem document, and an Error without a status becomes a 500. It isn’t passed to other hooks.

We can use .onError(ErrorClass, handler) to map an error to a response.

This is useful for mapping errors from libraries that you have no control over to a response.

import { Elysia, problem } from 'elysia'
import { DrizzleQueryError } from 'drizzle-orm/errors'
import { db } from './db'
import { users } from './schema'
new Elysia()
.onError(DrizzleQueryError, () => problem(502, {
type: 'UPSTREAM_FAILURE',
detail: 'The database connection is unavailable'
}))
.get('/users', async () => {
try {
return await db.select().from(users)
} catch (error) {
if (error instanceof DrizzleQueryError)
return error
throw error
}
})

This maps DrizzleQueryError to a 502 response with a problem document.

A thrown DrizzleQueryError would reach the hook too. The try/catch only exists to return the error, so the 502 response becomes part of the route’s type.

If the response never changes, pass the value directly instead of a function:

new Elysia()
.onError(DrizzleQueryError, problem(502, { detail: 'Database unavailable' }))

Any Error with a status property is sent with that status and its message as a plain-text body. You don’t need HTTPError for this:

import { Elysia } from 'elysia'
class Teapot extends Error {
status = 418
}
new Elysia()
.get('/', () => {
throw new Teapot('Short and stout')
})

This responds 418 with Short and stout.

An error without a status that no hook handles becomes a 500 problem document:

{
"type": "internal-server-error",
"code": "internal-server-error",
"title": "Internal Server Error",
"status": 500,
"detail": "Maintenance window",
"name": "Error"
}

With NODE_ENV=production, Elysia omits detail and name so internal messages don’t leak to clients.

You can use HTTPError.id() to create a custom error class that Elysia can translate to HTTP responses.

By default, Elysia returns an RFC 9457 problem document:

import { Elysia, HTTPError } from 'elysia'
class CustomError extends HTTPError.id('UNIQUE_ID', 400) {}
new Elysia()
.get('/return', () => new CustomError('Invalid request'))
HTTP 400
content-type: application/problem+json

{
  "type": "UNIQUE_ID",
  "code": "UNIQUE_ID",
  "detail": "Invalid request",
  "status": 400,
  "title": "Bad Request"
}

You can add optional properties to customize how the error behaves:

PropertyDescription
headersadd to the response headers
detailset the detail string in the problem document
extension()add members to the problem document
value()override the entire response body

value() can be async.

The status can also be a name, such as HTTPError.id('OUT_OF_CREDIT', 'Payment Required').

Use detail to set the detail of the RFC 9457 problem document. It takes priority over the error message, so the message can stay in your logs:

import { Elysia, HTTPError } from 'elysia'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT', 402) {
headers = { 'x-billing-required': 'true' }
detail = 'Top up to continue'
}
new Elysia()
.get('/', () => new OutOfCredit())
HTTP 402
content-type: application/problem+json
x-billing-required: true

{
  "type": "OUT_OF_CREDIT",
  "code": "OUT_OF_CREDIT",
  "detail": "Top up to continue",
  "status": 402,
  "title": "Payment Required"
}

Use extension() to add your own members to the problem document, like the out-of-credit example from RFC 9457:

import { Elysia, HTTPError } from 'elysia'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT', 402) {
detail = 'Your current balance is 30, but that costs 50'
constructor(readonly balance: number) {
super()
}
extension() {
return { balance: this.balance, accounts: ['/account/12345'] }
}
}
new Elysia()
.get('/', () => new OutOfCredit(30))
{
"type": "OUT_OF_CREDIT",
"code": "OUT_OF_CREDIT",
"detail": "Your current balance is 30, but that costs 50",
"status": 402,
"balance": 30,
"accounts": [
"/account/12345"
],
"title": "Payment Required"
}

type, code and status always come from the error, so extension() can’t change them. It can set title and instance, and a detail string when the class doesn’t set detail.

extension() must return a plain object synchronously. Anything else, such as a Promise, is ignored, so use value() for async work. Members whose value is a function are left out.

Use value() when you need to replace the entire response body:

import { Elysia, HTTPError } from 'elysia'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT', 402) {
value() {
return { code: 'OUT_OF_CREDIT', ok: false }
}
}
new Elysia()
.get('/', () => new OutOfCredit())
HTTP 402
content-type: application/json

{
  "code": "OUT_OF_CREDIT",
  "ok": false
}

For a response that depends on the request, such as its path or headers, map the error with .onError(Class, handler) instead. The handler receives the request’s context:

import { Elysia, HTTPError, problem } from 'elysia'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT', 402) {}
new Elysia()
.onError(OutOfCredit, ({ path }) =>
problem(402, { detail: `Top up to access ${path}` })
)
.get('/', () => new OutOfCredit())

Use problem() or status() to choose a status code from the error’s values. Select a balance below to compare the responses:

import { Elysia, HTTPError, status } from 'elysia'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT') {
constructor(public balance: number) {
super()
}
value() {
if (this.balance > 0)
return status(500, {
detail: 'Something went wrong on our side',
balance: this.balance
})
return status(402, {
type: this.type,
code: this.code,
detail: 'Out of credit',
balance: this.balance
})
}
}
new Elysia()
.get('/balance/0', () => new OutOfCredit(0))
.get('/balance/1', () => new OutOfCredit(1))
HTTP 402
content-type: application/json

{
  "type": "OUT_OF_CREDIT",
  "code": "OUT_OF_CREDIT",
  "detail": "Out of credit",
  "balance": 0
}

Note:

  • Elysia uses value() first, then detail, then a detail from extension(), then the error message.
  • If value() returns undefined, Elysia falls through to detail and extension(). Otherwise, extension() isn’t called.
  • If value() or extension() throws, Elysia returns 500 without the error’s headers.
  • For a detail that needs async work, return problem() from an async value().

You can extend HTTPError, or extend Error and implement HTTPError, and it would work:

import { HTTPError } from 'elysia'
class Overdrawn extends HTTPError {
readonly type = 'OVERDRAWN'
readonly status = 402
}
class OutOfCredit extends Error implements HTTPError {
readonly type = 'OUT_OF_CREDIT'
readonly status = 402
readonly detail = 'Top up to continue'
}

It’s very important that type and status should have either readonly or as const to make it statically analyzable.

If status cannot be determined at compile time, you can use status() in value() instead but you must leave status undefined.

HTTPError.id() adds two things a hand-written class doesn’t get: a code member in the response, and the typeBase prefix below. A hand-written class sends its type as written.

You can set HTTPError.typeBase to a URL to prefix the type of classes made by HTTPError.id() and of the built-in errors. Validation errors keep type: 'validation'. This is useful for clients that want to look up the error type in a documentation page.

import { Elysia, HTTPError } from 'elysia'
HTTPError.typeBase = 'https://elysiajs.com/errors'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT', 402) {}
new Elysia()
.get('/', () => new OutOfCredit('Top up to continue'))
{
"type": "https://elysiajs.com/errors/OUT_OF_CREDIT",
"code": "OUT_OF_CREDIT",
"detail": "Top up to continue",
"status": 402,
"title": "Payment Required"
}

code keeps the bare identifier while type is an absolute URL if typeBase is set. Using code might be more convenient for clients that don’t need to look up the error type.

A returned error is part of the route’s response type, under its status. Eden narrows it the same way as a status() response:

import { Elysia, HTTPError } from 'elysia'
import { treaty } from '@elysia/eden'
class OutOfCredit extends HTTPError.id('OUT_OF_CREDIT', 402) {}
const app = new Elysia()
.get('/credit', () => new OutOfCredit('Top up to continue'))
const { error } = await treaty(app).credit.get()
if (error?.status === 402)
console.log(error.value.code) // 'OUT_OF_CREDIT'

Members returned by extension() are part of the type too.

Add error to a schema field to choose the message returned when that field fails validation.

import { Elysia, t } from 'elysia'
new Elysia()
.get('/:id', {
params: t.Object({
id: t.Number({ error: 'id must be a number' })
})
}, ({ params: { id } }) => id)
RequestResponse
/string422 with id must be a number
/1200 with 1

You can attach a message to the particular schema that may fail.

import { t } from 'elysia'
const Email = t.String({
format: 'email',
error: 'Invalid email'
})
const Names = t.Array(
t.String({ error: 'All members must be a string' }),
{ error: 'Expected an array of names' }
)
const Point = t.Object({ x: t.Number() }, {
error: 'Expected an object with a numeric x'
})

Names uses its item message for [1] and its array message for "hello".

Object and field messages are separate. The field message applies when the object is valid but a field fails. The object’s own message applies when the value isn’t an object at all, so Point’s message is returned for "hello".

The error callback applies when its schema fails. A nested number’s callback doesn’t handle a value that isn’t an object at all:

import { t } from 'elysia'
const Point = t.Object({
x: t.Number({
error() {
return 'Expected x to be a number'
}
})
})
JSON bodyMessage
{ "x": "hello" }Expected x to be a number
"hello"Default object validation message

Add a callback to the object itself to customize the second case:

import { t } from 'elysia'
const Point = t.Object({
x: t.Number({
error() {
return 'Expected x to be a number'
}
})
}, {
error() {
return 'Expected value to be an object'
}
})

Now the JSON string "hello" produces Expected value to be an object.

Wrap your message in validationDetail() to include validation issues alongside it. This is useful when callers need to know which field failed and why.

import { Elysia, t, validationDetail } from 'elysia'
new Elysia()
.get('/:id', {
params: t.Object({
id: t.Number({
error: validationDetail('id must be a number')
})
})
}, ({ params: { id } }) => id)

In development, /string returns a validation object containing your message and the validation issues. See the validation error tutorial for a walkthrough.

With NODE_ENV=production, Elysia omits detailed schema issues and submitted values from the validation response.

This is to avoid exposing sensitive data. Your message is still returned, but the response is reduced to a problem document:

{
"type": "validation",
"code": "validation",
"title": "Validation Error",
"status": 422,
"on": "params",
"property": "/id"
}

This only applies to the default validation message. A custom message is sent as written, as plain text for a string or JSON for an object, not as a problem document.

Use a function when you want to construct the message. You can return a string or another value suitable for your error response:

import { Elysia, t } from 'elysia'
new Elysia()
.post('/', {
body: t.Object({
x: t.Number({
error() {
return 'Expected x to be a number'
}
})
})
}, () => 'Hello World!')
.listen(3000)

error() receives an object with the following fields:

fielddescription
typeThe error type, always validation for schema errors
onWhere the error was found, such as body or params
valueThe submitted value (omitted in production)
errorsAn array of validation issues (omitted in production)

In production, value and errors are omitted to avoid exposing sensitive data. Use allowUnsafeValidationDetails to include them in production.

error.all is an array property, not a method. Each issue has a dotted path such as name or user.email, a message, a schemaPath, and params:

import { Elysia, t, ValidationError } from 'elysia'
new Elysia()
.post('/', {
body: t.Object({
name: t.String(),
age: t.Number()
}),
error({ error, status }) {
if (!(error instanceof ValidationError)) return
const name = error.all.find((issue) => issue.path === 'name')
return status(422, name?.message ?? error.message)
}
}, ({ body }) => body)
.listen(3000)

For the raw issues from the schema provider, read error.errors. TypeBox issues locate the field with an instancePath such as /name, while a Standard Schema validator may use a different shape.

Elysia uses these error classes. Each sends a problem document with the code below as code, and as type unless typeBase is set. Import a class to handle it with .onError().

ClassStatuscodeUsed when
NotFound404not-foundNo route matches
ParseError400parseThe request body can’t be parsed
ValidationError422validationA request fails its schema
InvalidCookie400invalid-cookieA signed cookie fails verification
InternalServerError500internal-server-errorYou throw it for a server failure

ParseError includes the parser’s message as detail in development only.

InvalidCookie returns 500 instead when the cookie secret itself is missing, since that is a server misconfiguration.

A response that fails its schema returns 500 instead of 422. In production, that response is a bare internal server error without validation details.

You can throw NotFound and InternalServerError yourself. A message you pass, such as new NotFound('No such tea'), is sent as detail, in production too.

Type to search the documentation.

    Up arrowDown arrow to navigateEnter to selectEscape to close