JSON in Elysia: Type-Safe Responses, Validation, and Eden
Last updated:
Elysia automatically serializes any object returned from a route handler to JSON — no res.json() call needed. For validated input, define a body schema using TypeBox (t.Object(...)) directly on the route and Elysia rejects invalid requests with a 422 error before your handler runs. The Eden treaty generates fully typed client methods from your Elysia app with zero code generation — just import the server type. Elysia runs exclusively on Bun and benchmarks at 2-3x the requests per second of Express for JSON endpoints. This guide covers 5 topics: automatic JSON serialization, TypeBox request validation, response type definitions, Eden end-to-end type safety, and error handling.
Automatic JSON Serialization in Elysia
Bottom line: return any plain object, array, or primitive from a route handler and Elysia sets Content-Type: application/json and serializes it automatically. There is no equivalent of Express's res.json() — the return value is the response body.
Elysia detects the JavaScript type of the return value and selects the correct serializer. Objects and arrays go through JSON serialization; strings are sent as-is with text/plain; Response objects bypass serialization entirely, giving you an escape hatch for custom headers or binary responses. To set a specific HTTP status code, use the set.status property available in the handler context. Custom headers go through set.headers. For consistent error shapes, pair set.status with a plain object return — Elysia serializes it the same way as a success response.
import { Elysia } from "elysia"
const app = new Elysia()
// Return an object — auto-serialized to JSON
.get("/users", () => [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
])
// Set status code via context
.post("/users", ({ set }) => {
set.status = 201
return { id: 3, name: "Charlie" }
})
// Add custom response headers
.get("/data", ({ set }) => {
set.headers["X-Total-Count"] = "42"
return { items: [], total: 42 }
})
// Return a raw Response to bypass auto-serialization
.get("/raw", () =>
new Response(JSON.stringify({ custom: true }), {
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
})
)
// Null and undefined are serialized to JSON null / empty body
.get("/empty", () => null)
.listen(3000)
console.log(`Elysia running at ${app.server?.url}`)Validate Request Bodies with TypeBox
Bottom line: pass a body option to any route using t.Object() from Elysia's bundled TypeBox. Elysia validates the incoming JSON before the handler runs and returns a structured 422 error if validation fails — your handler never receives invalid data.
TypeBox types map directly to JSON Schema, so you get full constraint support: t.String({ minLength: 1, maxLength: 100 }), t.Number({ minimum: 0 }), t.Optional() for non-required fields, and t.Nullable() for fields that can be null. The 422 error body includes a structured validation report with the exact field path and the expected vs received type, making it easy for clients to surface field-level errors without any extra work. Validation also applies to query strings (query), path parameters (params), and headers (headers) using the same t.* types.
import { Elysia, t } from "elysia"
const app = new Elysia()
// Basic body validation
.post("/users", ({ body }) => {
// body is fully typed: { name: string; age: number }
return { id: Date.now(), ...body }
}, {
body: t.Object({
name: t.String({ minLength: 1, maxLength: 100 }),
age: t.Number({ minimum: 0, maximum: 150 }),
}),
})
// Optional and nullable fields
.post("/posts", ({ body }) => body, {
body: t.Object({
title: t.String(),
content: t.String(),
tags: t.Optional(t.Array(t.String())),
publishedAt: t.Nullable(t.String({ format: "date-time" })),
}),
})
// Validate query params too
.get("/search", ({ query }) => ({ results: [], query: query.q }), {
query: t.Object({
q: t.String({ minLength: 1 }),
limit: t.Optional(t.Number({ minimum: 1, maximum: 100 })),
}),
})
.listen(3000)
// 422 error shape (auto-generated by Elysia on validation failure):
// {
// "type": "validation",
// "on": "body",
// "found": { "name": "", "age": -1 },
// "property": "/age",
// "message": "Expected number to be greater or equal to 0"
// }Define Response Types
Bottom line: add a response option to a route with TypeBox schemas keyed by HTTP status code. This unlocks compiled serialization (3-5x faster than JSON.stringify), TypeScript type inference for return values, and typed error responses in the Eden client.
Elysia uses the response schema to generate a compiled serializer via TypeBox's TypeCompiler. The compiled serializer knows the exact shape of the output at startup time and avoids the overhead of reflective serialization on every request. When you define multiple status codes, Eden treaty clients receive a discriminated union type — data is the 200 shape, error is the 400 or 422 shape. Response schemas also serve as live documentation: combine with the Swagger plugin to auto-generate accurate OpenAPI specs without writing YAML.
import { Elysia, t } from "elysia"
// Reusable response schemas
const UserSchema = t.Object({
id: t.Number(),
name: t.String(),
email: t.String(),
createdAt: t.String(),
})
const ErrorSchema = t.Object({
error: t.String(),
code: t.String(),
})
const app = new Elysia()
// Single status code response schema
.get("/users/:id", ({ params }) => ({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
createdAt: new Date().toISOString(),
}), {
params: t.Object({ id: t.String() }),
response: UserSchema, // compiled serializer for 200
})
// Multiple status codes — discriminated in Eden client
.post("/users", ({ body, set }) => {
if (!body.email.includes("@")) {
set.status = 400
return { error: "Invalid email", code: "INVALID_EMAIL" }
}
set.status = 201
return { id: Date.now(), ...body, createdAt: new Date().toISOString() }
}, {
body: t.Object({ name: t.String(), email: t.String() }),
response: {
201: UserSchema,
400: ErrorSchema,
},
})
.listen(3000)
export type App = typeof appEnd-to-End Type Safety with Eden
Bottom line: export typeof app from your server file, import it in the client, and pass it to treaty(). Eden generates a fully typed proxy object that mirrors your API's route structure — no code generation, no schema export, no build step.
Eden treaty works by exploiting TypeScript's structural type system at compile time. The treaty() function is generic over the Elysia app type and maps route paths to typed async methods. Path parameters become arguments (api.users({ id: 1 }).get()), query parameters become the query option, and request bodies are the first argument of mutation methods. The response is always a Promise<{ data: T | null; error: E | null }> discriminated union — check error before using data. Eden requires a shared TypeScript project between the server and client; in a monorepo, export the type from the server package and import it in the client package.
// server.ts — export the app type
import { Elysia, t } from "elysia"
export const app = new Elysia()
.get("/users", () => [{ id: 1, name: "Alice" }])
.get("/users/:id", ({ params }) => ({ id: Number(params.id), name: "Alice" }), {
params: t.Object({ id: t.String() }),
})
.post("/users", ({ body }) => ({ id: Date.now(), ...body }), {
body: t.Object({ name: t.String(), email: t.String() }),
})
.listen(3000)
export type App = typeof app
// client.ts — import the type, create a typed client
import { treaty } from "@elysiajs/eden"
import type { App } from "./server"
const api = treaty<App>("localhost:3000")
// GET /users — response is typed as { id: number; name: string }[]
const { data: users, error } = await api.users.get()
if (error) throw new Error(error.message)
console.log(users[0].name) // TypeScript knows this is string
// GET /users/:id — path param in object notation
const { data: user } = await api.users({ id: 1 }).get()
console.log(user?.id) // number | undefined
// POST /users — body is typed, response is typed
const { data: created, error: createErr } = await api.users.post({
name: "Bob",
email: "bob@example.com",
})
if (createErr) throw new Error(createErr.message)
console.log(created?.id) // number
// With query params
const { data: found } = await api.search.get({ query: { q: "alice", limit: 10 } })Handle JSON Errors in Elysia
Bottom line: use app.onError({ error, set } => ...) for global error handling. For domain-specific errors, create custom error classes and throw them from handlers — Elysia catches them and routes to onError. Define per-route error schemas for typed error responses.
Elysia's error lifecycle fires for any thrown error, including TypeBox validation failures (which have error.code === "VALIDATION") and built-in NotFoundError / ParseError. The set.status helper in the onError context sets the HTTP status code for the error response. For structured API errors, the recommended pattern is a custom ApiError class with a code and statusCode field, thrown from handlers and caught in onError to format a consistent JSON error envelope. Combine with the response error schemas so Eden clients receive typed error shapes rather than unknown.
import { Elysia, t, NotFoundError } from "elysia"
// Custom error class
class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
) {
super(message)
this.name = "ApiError"
}
}
const app = new Elysia()
// Global error handler
.onError(({ error, set }) => {
if (error instanceof ApiError) {
set.status = error.statusCode
return { error: error.message, code: error.code }
}
if (error instanceof NotFoundError) {
set.status = 404
return { error: "Resource not found", code: "NOT_FOUND" }
}
// Validation errors (TypeBox)
if (error.message.startsWith("Validation")) {
set.status = 422
return { error: error.message, code: "VALIDATION_ERROR" }
}
// Fallback — don't leak internal details
set.status = 500
return { error: "Internal server error", code: "INTERNAL_ERROR" }
})
// Throw custom errors from handlers
.get("/users/:id", ({ params }) => {
const id = Number(params.id)
if (isNaN(id) || id <= 0) {
throw new ApiError(400, "INVALID_ID", "User ID must be a positive integer")
}
// Simulate not found
if (id > 100) {
throw new NotFoundError()
}
return { id, name: "Alice" }
}, {
params: t.Object({ id: t.String() }),
response: {
200: t.Object({ id: t.Number(), name: t.String() }),
400: t.Object({ error: t.String(), code: t.String() }),
404: t.Object({ error: t.String(), code: t.String() }),
},
})
.listen(3000)Elysia Plugins for JSON APIs
Bottom line: Elysia's official plugins extend the framework with CORS, authentication, and automatic OpenAPI documentation — all installable via bun add and registered with .use().
The @elysiajs/cors plugin handles preflight OPTIONS requests and injects Access-Control-Allow-* headers automatically — no manual header management per route. The @elysiajs/bearer plugin extracts Bearer tokens from the Authorization header and makes them available as context.bearer. The @elysiajs/swagger plugin generates a live OpenAPI 3.0 spec from your route schemas (TypeBox body, query, params, and response definitions) and serves an interactive Swagger UI at /swagger with zero configuration. All three plugins use Elysia's lifecycle hooks internally and add no meaningful overhead to request handling.
// bun add @elysiajs/cors @elysiajs/bearer @elysiajs/swagger
import { Elysia, t } from "elysia"
import { cors } from "@elysiajs/cors"
import { bearer } from "@elysiajs/bearer"
import { swagger } from "@elysiajs/swagger"
const app = new Elysia()
// CORS — allow all origins for a public API
.use(cors({
origin: "*",
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
}))
// Swagger UI at /swagger, OpenAPI spec at /swagger/json
.use(swagger({
documentation: {
info: { title: "My API", version: "1.0.0" },
tags: [{ name: "users", description: "User operations" }],
},
}))
// Bearer token extraction — available as context.bearer
.use(bearer())
// Protect routes with Bearer auth
.get("/me", ({ bearer, set }) => {
if (!bearer) {
set.status = 401
return { error: "Unauthorized", code: "MISSING_TOKEN" }
}
// Validate the token against your auth system...
return { id: 1, name: "Alice", token: bearer }
}, {
response: {
200: t.Object({ id: t.Number(), name: t.String(), token: t.String() }),
401: t.Object({ error: t.String(), code: t.String() }),
},
detail: { tags: ["users"], summary: "Get current user" },
})
.listen(3000)
// Swagger UI available at: http://localhost:3000/swagger
// OpenAPI JSON at: http://localhost:3000/swagger/jsonInspect your Elysia JSON responses
Paste JSON from your Elysia API into Jsonic's formatter to validate structure and explore nested data.
Open JSON FormatterFAQ
How does Elysia serialize JSON responses?
Return any JavaScript object or array from a route handler — Elysia automatically serializes it to JSON and sets Content-Type: application/json. No res.json() call is needed. If you define a response schema on the route, Elysia uses a compiled TypeBox serializer that runs 3-5x faster than JSON.stringify.
How do I validate a JSON request body in Elysia?
Add a body option to your route using TypeBox: { body: t.Object({ name: t.String() }) }. Elysia validates the incoming JSON before the handler runs. Invalid bodies return a 422 with a structured error report showing the field path, expected type, and received value. Fields can be made optional with t.Optional() or nullable with t.Nullable().
What is Eden in Elysia and how does it work?
Eden is Elysia's end-to-end type-safety client. Export typeof app from your server, then pass it to treaty<App>("localhost:3000") on the client. Eden creates a typed proxy that mirrors your route structure. Calling api.users.get() returns a fully typed response with no code generation or separate schema files. Any breaking server change surfaces immediately as a TypeScript error in client code.
How do I handle JSON errors in Elysia?
Use app.onError({ error, set } => ...) for global error handling. Set set.status to the desired HTTP code and return a plain object — Elysia serializes it to JSON. For custom domain errors, throw instances of a custom class extending Error from any handler; onError catches them. Define per-route response schemas keyed by status code for typed error shapes in Eden clients.
Is Elysia faster than Hono for JSON APIs?
Yes, in most JSON benchmarks on Bun. Elysia achieves approximately 150k req/s vs Hono's ~120k req/s for typed JSON endpoints on the same hardware, mainly because Elysia's compiled TypeBox serializer avoids reflective serialization overhead. For untyped endpoints the gap is smaller. Both are dramatically faster than Express (~55k req/s on Bun). Hono's advantage is multi-runtime support (Node.js, Deno, Cloudflare Workers), while Elysia is Bun-only.
Can Elysia run on Node.js instead of Bun?
No. Elysia is built exclusively for Bun and uses Bun.serve() internally. It will not start on Node.js. If you need a similar framework that supports Node.js, consider Hono (multi-runtime), Fastify (Node.js-native with a similar plugin model), or NestJS. Elysia intentionally limits itself to Bun to maximize performance and leverage Bun-native APIs.
Further reading and primary sources
- Elysia Documentation — Official Elysia.js documentation covering installation, routing, lifecycle hooks, and the plugin system
- Eden Treaty — Official Eden treaty documentation with setup instructions, typed client usage, and monorepo patterns
- Elysia TypeBox validation — Official guide to Elysia validation using TypeBox — body, query, params, headers, and response schemas
- Elysia plugins — Overview of official Elysia plugins including CORS, Bearer auth, Swagger, and community plugins
- TypeBox — TypeBox GitHub repository — JSON Schema type builder with compiled validators and serializers used internally by Elysia