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.
First: Prefer return over throw
Section titled “First: Prefer return over throw”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 document
Section titled “Problem document”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' }).
Error hook
Section titled “Error hook”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.
Mapping Errors
Section titled “Mapping Errors”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' }))Error status code
Section titled “Error status code”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.
Unhandled errors
Section titled “Unhandled errors”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.
Custom error
Section titled “Custom error”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"
}Error response
Section titled “Error response”You can add optional properties to customize how the error behaves:
| Property | Description |
|---|---|
headers | add to the response headers |
detail | set 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, thendetail, then adetailfromextension(), then the error message. - If
value()returnsundefined, Elysia falls through todetailandextension(). Otherwise,extension()isn’t called. - If
value()orextension()throws, Elysia returns500without the error’s headers. - For a
detailthat needs async work, returnproblem()from anasync value().
HTTPError.id is syntax sugar
Section titled “HTTPError.id is syntax sugar”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.
Problem type URL
Section titled “Problem type URL”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.
Response type
Section titled “Response 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.
Schema Validation
Section titled “Schema Validation”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)| Request | Response |
|---|---|
/string | 422 with id must be a number |
/1 | 200 with 1 |
Schema messages
Section titled “Schema messages”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".
Error is called per field
Section titled “Error is called per field”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 body | Message |
|---|---|
{ "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.
Validation detail
Section titled “Validation detail”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.
Validation detail in production
Section titled “Validation detail in production”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.
Error message as function
Section titled “Error message as function”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:
| field | description |
|---|---|
type | The error type, always validation for schema errors |
on | Where the error was found, such as body or params |
value | The submitted value (omitted in production) |
errors | An 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 list
Section titled “Error list”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.
Built-in errors
Section titled “Built-in errors”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().
| Class | Status | code | Used when |
|---|---|---|---|
NotFound | 404 | not-found | No route matches |
ParseError | 400 | parse | The request body can’t be parsed |
ValidationError | 422 | validation | A request fails its schema |
InvalidCookie | 400 | invalid-cookie | A signed cookie fails verification |
InternalServerError | 500 | internal-server-error | You 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.