Mastering Elysia: A Complete Guide.
A systematic guide to mastering Elysia's core concepts and practical techniques, based on the Feynman Technique, Simon's Learning Method, SQ3R Reading Method, and Cornell Note-taking System.
1. Overview & Questions (SQ3R · Survey & Question)
SQ3R Step 1: Survey the landscape, ask the key questions.
What is Elysia?
Elysia is a TypeScript web framework built on the Bun runtime, designed as an "Ergonomic Framework for Humans." It combines End-to-End Type Safety, formidable performance, and exceptional developer experience into a single cohesive package, making it the most popular production-ready framework in the Bun ecosystem.
Maintained by SaltyAom since 2022, Elysia is used in production by companies including X (formerly Twitter), CS.Money, the Bank for Agriculture and Agricultural Cooperatives of Thailand, and over 10,000 open-source projects on GitHub.
Elysia's core design principles:
- Single Source of Truth — One schema serves runtime validation, TypeScript type inference, OpenAPI documentation generation, and client-server type synchronization simultaneously.
- Write Less TypeScript — The framework infers types automatically so you can focus on business logic.
- Web Standards — Built on
Request/Response, runnable on Bun, Node.js, Deno, Cloudflare Workers, and more. - Method Chaining — All APIs use method chaining to guarantee type safety at every step.
Core Questions
- When should I use Elysia? — Building high-performance RESTful APIs, full-stack TypeScript projects, microservices requiring end-to-end type safety, and real-time communication (WebSocket) scenarios.
- How does Elysia compare to Express, Fastify, or Hono? — Approximately 21x faster than Express, 6x faster than Fastify; a more advanced type system than any other framework; native OpenAPI and end-to-end type safety support.
- How does Elysia compare to tRPC? — Built on RESTful standards while offering tRPC-level type safety; automatic OpenAPI documentation generation; follows HTTP conventions for easier integration with other systems.
- What prerequisites do I need? — TypeScript fundamentals (recommended but not required), basic understanding of HTTP, and familiarity with Node.js or Bun.
Technical Landscape
Elysia's architecture can be understood in five layers:
Foundation: Routing System — Define HTTP routes, path parameters, query parameters, and request body handling. The building blocks of any web service.
Validation: Elysia.t (TypeBox) — A schema builder based on TypeBox with support for Standard Schema (Zod, Valibot, etc.), providing a single source of truth for runtime validation and compile-time type inference.
Middle: Lifecycle — An event-driven request processing pipeline with stages like onRequest, onParse, onTransform, onBeforeHandle, onAfterHandle, and onError, replacing the traditional middleware pattern.
Advanced: Plugins & Macros — Compose functionality with use(), apply shared schemas with guard, define reusable route options with macro, and organize routes with group.
Ecosystem: Eden Treaty — An end-to-end type-safe client library that synchronizes types between frontend and backend without code generation.
2. Explain It Simply (Feynman Technique)
Feynman Technique: If you can't explain something in simple language, you don't truly understand it.
Core Concepts
1. Routing
Routing is simply telling the server "when someone visits a URL, do this." Think of it as a restaurant menu — you order "item 1," and the waiter knows exactly what to serve.
import { Elysia } from "elysia";
new Elysia()
.get("/", "Hello Elysia") // Visit homepage, return text
.get("/user/:id", ({ params }) => params.id) // Dynamic path
.post("/form", ({ body }) => body) // Accept form data
.listen(3000);2. Validation
Validation means "checking that the data makes sense." For example, age should be a number, not text. Elysia uses Elysia.t (shorthand: t) to define rules, and those same rules automatically become TypeScript types.
import { Elysia, t } from "elysia";
new Elysia()
.get("/user/:id", ({ params: { id } }) => id, {
params: t.Object({
id: t.Number(), // id must be a number
}),
})
.listen(3000);3. Lifecycle
The lifecycle is like stations on an assembly line. A request passes through: receive → parse → transform → validate → pre-handle → execute → post-handle → map response → send. At each station, you can plug in your own logic.
new Elysia()
.onRequest(() => console.log("Request received"))
.onBeforeHandle(({ headers, status }) => {
// Check permissions before handling
if (!headers.authorization) return status(401);
})
.get("/", () => "Hello")
.listen(3000);4. Plugins
A plugin is "a bundle of functionality that you can plug into any server." Every Elysia instance can run independently or be composed into other instances with use().
const logger = new Elysia().onRequest(({ request }) => console.log(request.url));
const app = new Elysia()
.use(logger) // Add logging in one line
.get("/", "Hello")
.listen(3000);5. Eden Treaty (End-to-End Type Safety)
Imagine you're calling a colleague in another city. If you speak the same language, communication is smooth. Eden Treaty ensures the frontend and backend "speak the same language" — types defined on the server are automatically available on the client without manual synchronization.
// Server
export const app = new Elysia().get("/hi", () => "Hi Elysia").listen(3000);
export type App = typeof app;
// Client
import { treaty } from "@elysiajs/eden";
import type { App } from "./server";
const api = treaty<App>("localhost:3000");
const { data } = await api.hi.get(); // data is automatically typed as stringAnalogies
- Elysia vs Express: Express is like a manual car — flexible but requires more hands-on work. Elysia is like a self-driving car — you state your destination (define a Schema), and it handles validation, type inference, and documentation automatically.
- Lifecycle vs Middleware: Traditional middleware is like a single queue — everyone passes through the same line. Lifecycle is like a sorting facility — each package enters different processing channels at different stages.
- Elysia.t vs TypeScript Types: TypeScript types exist only at compile time and vanish at runtime. Elysia.t works at both compile time and runtime — true "one definition, everywhere."
- Eden Treaty vs tRPC: tRPC invented its own communication protocol; you need to learn new APIs. Eden Treaty lets you continue using standard HTTP methods while gaining automatic type safety.
Common Misconceptions
Misconception 1: Elysia only runs on Bun.
In reality, Elysia is built on Web Standards (Request/Response) and can run on Node.js, Deno, Cloudflare Workers, Vercel Edge Functions, and more through adapters. Bun is simply the runtime that delivers the best performance.
Misconception 2: TypeScript is required.
TypeScript is not required, but it is strongly recommended. Elysia's type system is its core advantage — without TypeScript, you lose the biggest selling point.
Misconception 3: Elysia's performance comes entirely from Bun.
While Bun provides a high-performance foundation, Elysia further optimizes through Static Code Analysis and Ahead-of-Time (AoT) compilation — generating optimized route handling code at server startup.
3. Deep Dive (Simon's Learning Method)
Focused, goal-oriented, cone-shaped deepening — start from the core and expand outward.
Layer 1: Core Fundamentals
Route Definition
Elysia uses method chaining to define routes. Each route has three parts: HTTP method, path, and handler function.
import { Elysia } from "elysia";
new Elysia()
.get("/", "Hello World") // Return a string
.get("/json", () => ({ hello: "Elysia" })) // Auto-converted to JSON
.post("/data", ({ body }) => body) // Accept and return body
.put("/update", ({ body }) => body)
.delete("/remove", () => "Deleted")
.all("/any", () => "Any method") // Match all HTTP methods
.listen(3000);Path Parameters
Use :name syntax for dynamic path segments, accessed via params.
// Dynamic path parameter
.get('/user/:id', ({ params: { id } }) => `User ${id}`)
// Multiple path parameters
.get('/post/:postId/comment/:commentId', ({ params }) => params)
// Optional path parameter (suffix with ?)
.get('/page/:page?', ({ params: { page } }) => `Page ${page ?? 1}`)
// Wildcard path
.get('/files/*', ({ params }) => params['*'])Path priority: static paths > dynamic paths > wildcards.
Query Parameters
Query parameters are automatically parsed into an object, accessed via query.
.get('/search', ({ query }) => query)
// GET /search?keyword=elysia&page=1 → { keyword: "elysia", page: "1" }Note: Query parameter values are always strings. Use t.Number() with schema validation to let Elysia auto-coerce them.
Request Body
Access the request body through body. Elysia auto-parses JSON, FormData, and URL-encoded formats.
import { Elysia, t } from "elysia";
new Elysia()
.post("/user", ({ body }) => body, {
body: t.Object({
name: t.String(),
age: t.Number(),
email: t.String({ format: "email" }),
}),
})
.listen(3000);Response Handling
Elysia automatically converts return values into appropriate HTTP responses: strings return text/plain, objects return application/json.
// String response
.get('/', () => 'Hello')
// JSON response (automatic)
.get('/json', () => ({ message: 'Hello' }))
// Custom status code
.get('/teapot', ({ status }) => status(418, "I'm a teapot"))
// Custom response headers
.get('/', ({ set }) => {
set.headers['x-powered-by'] = 'Elysia'
return 'Hello'
})
// Redirect
.get('/redirect', ({ redirect }) => redirect('https://elysiajs.com'))File Handling
import { Elysia, file } from 'elysia'
// Return a static file
.get('/image', file('public/photo.webp'))
// File upload
.post('/upload', ({ body }) => body.file, {
body: t.Object({
file: t.File({ type: 'image' })
})
})
// Multiple file upload
.post('/uploads', ({ body }) => body.files, {
body: t.Object({
files: t.Files()
})
})Streaming & SSE
import { Elysia, sse } from 'elysia'
// Generator stream
.get('/stream', function* () {
yield 'Hello'
yield 'World'
})
// Server-Sent Events
.get('/sse', function* () {
yield sse({ event: 'message', data: 'Hello' })
yield sse({ event: 'message', data: 'World' })
yield sse({ event: 'done' })
})Layer 2: Advanced Usage
Lifecycle Hooks
Elysia's lifecycle is a series of event stages where you can inject custom logic at each point.
import { Elysia } from "elysia";
new Elysia()
// 1. On request (earliest stage — rate limiting, caching, etc.)
.onRequest(({ request }) => {
console.log(`Request: ${request.url}`);
})
// 2. Parse body (custom body parser)
.onParse(({ request, contentType }) => {
if (contentType === "application/custom") return request.text();
})
// 3. Transform (modify context before validation)
.onTransform(({ params }) => {
if (params.id) params.id = +params.id;
})
// 4. Before handle (after validation — auth checks, etc.)
.onBeforeHandle(({ headers, status }) => {
if (!headers.authorization) return status(401);
})
// 5. After handle (modify response)
.onAfterHandle(({ set }) => {
set.headers["content-type"] = "application/json";
})
// 6. Error handling
.onError(({ code, error }) => {
if (code === "NOT_FOUND") return "Not Found :(";
return new Response(error.toString());
})
// 7. After response (logging, cleanup)
.onAfterResponse(({ set }) => {
console.log(`Response: ${set.status}`);
})
.get("/", () => "Hello")
.listen(3000);Hooks come in two types:
- Local Hook: Passed as the third argument to a route, applies only to that route
- Interceptor Hook: Registered via
onXxxmethods, applies to all routes registered after it
// Local hook
.get('/protected', () => 'Secret', {
beforeHandle({ headers, status }) {
if (!headers.authorization) return status(401)
}
})
// Interceptor hook
.onBeforeHandle(({ headers, status }) => {
if (!headers.authorization) return status(401)
})
.get('/protected', () => 'Secret') // The above beforeHandle is applied automaticallyPlugin System
Every Elysia instance is independent and can be composed with use(). This is Elysia's core composition pattern.
// Create a configurable plugin
const auth = (secret: string) =>
new Elysia({ name: "auth" })
.derive({ as: "global" }, ({ headers }) => {
const token = headers.authorization?.replace("Bearer ", "");
return { token };
})
.onBeforeHandle(({ token, status }) => {
if (!token) return status(401);
});
// Use the plugin
const app = new Elysia()
.use(auth("my-secret"))
.get("/profile", ({ token }) => `Token: ${token}`)
.listen(3000);Plugin Deduplication: Setting a name property enables automatic deduplication.
const ip = new Elysia({ name: "ip" }).derive({ as: "global" }, ({ server, request }) => ({
ip: server?.requestIP(request),
}));
// Even if used multiple times, ip is registered only once
const app = new Elysia().use(ip).use(ip); // Won't execute againEncapsulation & Scope
By default, Elysia lifecycle hooks are encapsulated — hooks defined in a plugin don't affect the parent instance. This is a key difference from Express middleware.
Three scope levels:
- local (default): Applies only to the current instance and its descendants
- scoped: Extends to the direct parent instance
- global: Extends to all instances using the plugin
const authPlugin = new Elysia().onBeforeHandle({ as: "scoped" }, ({ cookie, status }) => {
if (!cookie.session.value) return status(401);
});
const app = new Elysia()
.use(authPlugin)
.get("/profile", () => "Protected") // Protected by authPlugin
.listen(3000);Guard
Guard applies schemas and hooks to multiple routes at once.
new Elysia()
.guard(
{
body: t.Object({
username: t.String(),
password: t.String(),
}),
},
(app) => app.post("/sign-in", ({ body }) => body).post("/sign-up", ({ body }) => body),
)
.get("/", () => "No validation needed")
.listen(3000);Route Groups
Use group to organize routes under a prefix:
new Elysia()
.group("/api", (app) => app.get("/users", () => "Users").post("/users", () => "Create User"))
.listen(3000);
// Equivalent to /api/users GET and /api/users POST
// You can also set prefix in the constructor
const api = new Elysia({ prefix: "/api" }).get("/users", () => "Users");Extending Context
Use state, decorate, derive, and resolve to extend the request context:
// state: global mutable state
new Elysia().state("counter", 0).get("/", ({ store }) => {
store.counter++;
return `Count: ${store.counter}`;
});
// decorate: global immutable property
new Elysia().decorate("logger", new Logger()).get("/", ({ logger }) => {
logger.log("Hello");
return "ok";
});
// derive: derive new properties before validation
new Elysia()
.derive(({ headers }) => ({
bearer: headers.authorization?.replace("Bearer ", ""),
}))
.get("/", ({ bearer }) => bearer);
// resolve: derive new properties after validation (safer)
new Elysia()
.guard({
headers: t.Object({
authorization: t.String(),
}),
})
.resolve(({ headers: { authorization } }) => ({
token: authorization.split(" ")[1],
}))
.get("/", ({ token }) => token);Cookie Handling
Elysia uses a reactive signal pattern for cookies:
import { Elysia, t } from "elysia";
new Elysia()
.get(
"/",
({ cookie: { visit } }) => {
visit.value ??= 0;
visit.value++;
visit.httpOnly = true;
visit.set({
sameSite: "lax",
secure: true,
maxAge: 60 * 60 * 24 * 7,
});
return `Visited ${visit.value} times`;
},
{
cookie: t.Cookie({
visit: t.Optional(t.Number()),
}),
},
)
.listen(3000);Cookie signing:
new Elysia({
cookie: {
secret: "my-secret-key",
},
}).get(
"/",
({ cookie: { session } }) => {
session.value = "encrypted-value";
return "ok";
},
{
cookie: t.Cookie(
{
session: t.String(),
},
{
secrets: "my-secret-key",
sign: ["session"],
},
),
},
);Error Handling
import { Elysia } from "elysia";
class AppError extends Error {
status = 400;
constructor(message: string) {
super(message);
}
}
new Elysia()
.error({ APP_ERROR: AppError })
.onError(({ code, error, status }) => {
switch (code) {
case "APP_ERROR":
return status(error.status, { message: error.message });
case "NOT_FOUND":
return status(404, "Not Found");
case "VALIDATION":
return status(422, error.message);
default:
return status(500, "Internal Server Error");
}
})
.get("/", () => {
throw new AppError("Something went wrong");
})
.listen(3000);WebSocket
Elysia has built-in WebSocket support powered by µWebSocket:
import { Elysia, t } from "elysia";
new Elysia()
.ws("/chat", {
body: t.String(),
response: t.String(),
open(ws) {
console.log("Client connected");
},
message(ws, message) {
ws.send(`Echo: ${message}`);
},
close(ws) {
console.log("Client disconnected");
},
})
.listen(3000);Macro System
Macros are Elysia's unique feature for defining reusable route options — like functions, but as route-level configuration:
import { Elysia, t } from "elysia";
new Elysia()
.macro({
auth: {
cookie: t.Object({ session: t.String() }),
beforeHandle({ cookie: { session }, status }) {
if (!session.value) return status(401);
},
},
})
.get("/profile", () => "Profile", { auth: true })
.post("/settings", () => "Settings", { auth: true })
.listen(3000);OpenAPI Documentation
Generate API documentation with a single line:
import { Elysia, t } from "elysia";
import { openapi } from "@elysiajs/openapi";
new Elysia()
.use(openapi())
.get("/user/:id", ({ params: { id } }) => id, {
params: t.Object({ id: t.Number() }),
detail: {
summary: "Get user by ID",
tags: ["User"],
},
})
.listen(3000);Visiting /openapi shows the auto-generated API documentation (Scalar UI by default).
Layer 3: In-Depth Analysis
How End-to-End Type Safety Works
The core principle: export TypeScript types from the Elysia instance, and the client consumes them through Eden Treaty.
Server:
// server.ts
import { Elysia, t } from "elysia";
export const app = new Elysia()
.get("/hi", () => "Hi Elysia")
.post("/user", ({ body }) => body, {
body: t.Object({
name: t.String(),
age: t.Number(),
}),
response: {
200: t.Object({ name: t.String(), age: t.Number() }),
400: t.Object({ error: t.String() }),
},
})
.listen(3000);
export type App = typeof app; // Export the typeClient:
// client.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "./server";
const api = treaty<App>("localhost:3000");
// Fully type-safe with auto-completion
const { data, error } = await api.user.post({
name: "Elysia",
age: 1,
});
// data is inferred based on response status codes
// error is also precisely typed, covering all possible error statusesKey mechanisms:
- The Elysia instance type captures all route information (paths, methods, request schemas, response schemas).
typeof appelevates runtime code to compile-time types.- Eden Treaty parses this type to construct a type-safe client API.
- No code generation — pure TypeScript type inference.
Type Soundness: Elysia infers not just the "happy path" (200 OK) but all possible error status codes and their corresponding error types. This is something most other end-to-end type-safe frameworks (like tRPC) typically cannot do.
Eden Treaty Deep Dive
Eden Treaty maps the Elysia server into a tree-structured client object:
const api = treaty<App>("localhost:3000");
// Path / → api.get()
const { data } = await api.get();
// Path /user/:id → api.user({ id: 123 }).get()
const { data } = await api.user({ id: 123 }).get();
// Path /api/deep/nested → api.api.deep.nested.post({ ... })
const { data } = await api.api.deep.nested.post({ body: "data" });Eden Treaty also accepts an Elysia instance directly (for testing or micro-service communication without network overhead):
import { treaty } from "@elysiajs/eden";
const api = treaty(app); // Pass instance directly, no network calls
const { data } = await api.hi.get();Performance Optimization
Elysia's performance advantage comes from three layers:
1. Bun Runtime — Bun uses the JavaScriptCore (JSC) engine instead of V8, with faster startup times and an HTTP server built on µWebSocket.
2. Static Code Analysis — Elysia analyzes your code at startup to determine which properties (headers, query, body, etc.) need parsing, only parsing what's necessary.
3. Ahead-of-Time (AoT) Compilation — Elysia's built-in JIT "compiler" pre-compiles route handling logic into optimized code before the server starts:
// AoT is enabled by default
new Elysia({ aot: true });Performance benchmarks (requests/second):
| Framework | Runtime | Average |
|---|---|---|
| Elysia | Bun | 255,574 |
| Hono | Bun | 203,937 |
| Fastify | Node | 60,322 |
| Express | Node | 15,913 |
Comparison with Other Frameworks
Elysia vs Express:
- Performance: Elysia is ~21x faster
- Type safety: Elysia has complete end-to-end type safety; Express has none
- Middleware pattern: Express uses queue-based middleware; Elysia uses event-driven lifecycle
- Encapsulation: Express middleware applies globally by default; Elysia is encapsulated by default
Elysia vs Hono:
- Performance: Elysia is ~25% faster than Hono
- Type safety: Elysia offers a more sound type system (including error status code inference)
- OpenAPI: Elysia has built-in support; Hono requires extra configuration
- Target platform: Hono was originally designed for Cloudflare Workers; Elysia was originally designed for Bun
Elysia vs tRPC:
- Protocol: Elysia is based on RESTful standards; tRPC uses a proprietary RPC protocol
- OpenAPI: Elysia supports it natively; tRPC requires third-party libraries
- Learning curve: Elysia uses standard HTTP concepts; tRPC requires learning new APIs
- Type safety: Both support it, but Elysia also includes error status type inference
Standard Schema Support
Elysia supports multiple validation libraries — choose your favorite:
import { Elysia, t } from "elysia";
import { z } from "zod";
import * as v from "valibot";
new Elysia()
// TypeBox (built-in)
.post("/typebox", ({ body }) => body, {
body: t.Object({ name: t.String() }),
})
// Zod
.post("/zod", ({ body }) => body, {
body: z.object({ name: z.string() }),
})
// Valibot
.post("/valibot", ({ body }) => body, {
body: v.object({ name: v.string() }),
});Deployment Strategies
Compile to binary (recommended):
bun build --compile --minify-whitespace --minify-syntax \
--target bun --outfile server src/index.tsThe compiled binary typically reduces memory usage by 2-3x.
Docker deployment:
FROM oven/bun AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install
COPY ./src ./src
RUN bun build --compile --minify-whitespace --minify-syntax \
--outfile server src/index.ts
FROM gcr.io/distroless/base
WORKDIR /app
COPY --from=build /app/server server
CMD ["./server"]
EXPOSE 3000Vercel deployment:
// src/index.ts
import { Elysia, t } from "elysia";
export default new Elysia().get("/", () => "Hello Vercel").listen(3000);vc deploy4. Key Notes (Cornell Note-taking)
Quick Reference Table
| Cue/Keyword | Detailed Notes |
|---|---|
| Routing | .get(), .post(), .put(), .delete(), .all(), .route() to define routes; method chaining required |
| Path Parameters | :name dynamic, :name? optional, * wildcard; accessed via params |
| Query Parameters | URL ?key=value portion; accessed via query; values are strings by default |
| Request Body | Accessed via body; auto-parses JSON, FormData, URL-encoded |
| Response | Direct return value; status() for status codes; set.headers for headers; redirect() for redirects |
| Validation | t.Object(), t.String(), t.Number(), etc.; validates body, query, params, headers, cookie, response |
| Lifecycle | onRequest → onParse → onTransform → Validation → onBeforeHandle → Handler → onAfterHandle → onMapResponse → onError → onAfterResponse |
| Plugins | .use() to compose instances; name property for deduplication; encapsulated by default |
| Scope | local (default, isolated), scoped (extends to parent), global (everywhere) |
| Guard | Bulk-apply schemas and hooks; guard(schema, callback) |
| Group | group(prefix, callback) to organize route prefixes |
| Context Extension | state (mutable global), decorate (immutable global), derive (pre-validation), resolve (post-validation) |
| Cookie | Reactive signal pattern; cookie.name.value for read/write; t.Cookie() for schema |
| WebSocket | .ws() method; message, open, close callbacks; µWebSocket under the hood |
| Macro | .macro() to define reusable route options; { auth: true } to enable in one line |
| OpenAPI | @elysiajs/openapi generates docs in one line; fromTypes() for type-based generation |
| Eden Treaty | treaty<App>(url) creates a type-safe client; no code generation needed |
Core API Reference
| API / Method | Purpose | Example |
|---|---|---|
new Elysia() | Create an instance | new Elysia({ prefix: '/api' }) |
.get(path, handler, hook?) | Define GET route | .get('/', () => 'Hello') |
.post(path, handler, hook?) | Define POST route | .post('/user', ({ body }) => body) |
.ws(path, options) | Define WebSocket | .ws('/chat', { message(ws, msg) {} }) |
.use(plugin) | Use a plugin | .use(cors()) |
.guard(schema, callback) | Bulk-apply schema | .guard({ body: t.Object({...}) }, (app) => ...) |
.group(prefix, callback) | Route grouping | .group('/api', (app) => ...) |
.model(models) | Register reference models | .model({ user: t.Object({...}) }) |
.macro(definition) | Define a macro | .macro({ auth: { beforeHandle() {} } }) |
.state(key, value) | Set global state | .state('version', 1) |
.decorate(key, value) | Add context property | .decorate('logger', new Logger()) |
.derive(fn) | Derive property pre-validation | .derive(({ headers }) => ({ ... })) |
.resolve(fn) | Derive property post-validation | .resolve(({ body }) => ({ ... })) |
.onRequest(fn) | On-request hook | .onRequest(({ request }) => ...) |
.onBeforeHandle(fn) | Before-handle hook | .onBeforeHandle(({ status }) => ...) |
.onError(fn) | Error handling hook | .onError(({ code }) => ...) |
.listen(port) | Start server | .listen(3000) |
t.Object({}) | Define object schema | t.Object({ name: t.String() }) |
t.String() | String type | t.String({ format: 'email' }) |
t.Number() | Number type | t.Number({ minimum: 0 }) |
t.File() | File type | t.File({ type: 'image' }) |
t.Cookie({}) | Cookie schema | t.Cookie({ session: t.String() }) |
t.Optional() | Optional field | t.Optional(t.String()) |
t.Union([]) | Union type | t.Union([t.String(), t.Number()]) |
file(path) | Return static file | .get('/img', file('photo.webp')) |
sse(data) | SSE event | sse({ event: 'msg', data: 'hello' }) |
treaty<App>(url) | Create Eden client | treaty<App>('localhost:3000') |
Section Summary
Elysia's core can be summarized as a formula: Schema-driven single source of truth + Event-driven lifecycle + Composable plugin architecture = End-to-end type-safe RESTful framework.
Master these five key points, and you'll handle 80% of Elysia scenarios:
- Routing & Validation: Use
t.Object()to define schemas, gaining automatic type inference and runtime validation. - Lifecycle: Use hooks like
onBeforeHandleto insert custom logic in the request pipeline. - Plugin Composition: Use
use()to compose functionality,guardfor shared rules. - Encapsulation & Scope: Understand the difference between local/scoped/global scopes.
- Eden Treaty: Use
treaty<App>()to create an end-to-end type-safe client.
5. Review & Practice (SQ3R · Recite & Review)
Key Takeaways
-
Elysia is a Bun-based TypeScript web framework that provides end-to-end type safety, formidable performance, and exceptional developer experience.
-
Single Source of Truth is its core design — one schema serves runtime validation, TypeScript type inference, OpenAPI documentation, and client type synchronization.
-
The Lifecycle system replaces traditional middleware with more granular request processing control. Hooks are encapsulated by default, with scope mechanisms to control their reach.
-
Eden Treaty provides code-generation-free end-to-end type safety, with precise type inference for error status codes.
-
Performance optimization comes from the triple acceleration of Bun runtime + static code analysis + AoT compilation.
-
Cross-platform — built on Web Standards, runnable on Bun, Node.js, Deno, Cloudflare Workers, and more.
Hands-on Exercises
Exercise 1: Build a CRUD API with Validation
import { Elysia, t } from "elysia";
interface User {
id: number;
name: string;
email: string;
}
let users: User[] = [];
let nextId = 1;
new Elysia()
.post(
"/users",
({ body }) => {
const user = { id: nextId++, ...body };
users.push(user);
return user;
},
{
body: t.Object({
name: t.String({ minLength: 1 }),
email: t.String({ format: "email" }),
}),
},
)
.get("/users", () => users)
.get(
"/users/:id",
({ params: { id }, status }) => {
const user = users.find((u) => u.id === id);
if (!user) return status(404, "User not found");
return user;
},
{
params: t.Object({ id: t.Number() }),
},
)
.delete(
"/users/:id",
({ params: { id }, status }) => {
const index = users.findIndex((u) => u.id === id);
if (index === -1) return status(404, "User not found");
users.splice(index, 1);
return status(200, "Deleted");
},
{
params: t.Object({ id: t.Number() }),
},
)
.listen(3000);Exercise 2: Create an API with Auth Middleware
import { Elysia, t } from "elysia";
const auth = new Elysia({ name: "auth" }).macro({
auth: {
cookie: t.Object({ session: t.String() }),
beforeHandle({ cookie: { session }, status }) {
if (!session.value) return status(401);
},
},
});
new Elysia()
.use(auth)
.get("/public", () => "Anyone can see this")
.get("/private", () => "Only authenticated users", { auth: true })
.listen(3000);Exercise 3: Write Type-Safe Tests with Eden Treaty
import { describe, expect, it } from "bun:test";
import { Elysia } from "elysia";
import { treaty } from "@elysiajs/eden";
const app = new Elysia().get("/hello", () => "Hello World").post("/echo", ({ body }) => body);
const api = treaty(app);
describe("Elysia API", () => {
it("GET /hello returns Hello World", async () => {
const { data } = await api.hello.get();
expect(data).toBe("Hello World");
});
it("POST /echo echoes body", async () => {
const { data } = await api.echo.post({ message: "test" });
expect(data).toEqual({ message: "test" });
});
});Common Pitfalls
- Not using method chaining — Elysia's type system relies on method chaining to track type changes. Without it, type inference is lost.
// Wrong: not using method chaining
const app = new Elysia();
app.state("version", 1);
app.get("/", ({ store }) => store.version); // Type error!
// Correct: using method chaining
new Elysia().state("version", 1).get("/", ({ store }) => store.version); // Correct types- Wrong hook registration order — Lifecycle hooks only apply to routes registered after them. Place hooks before routes.
// Wrong: hook after route
.get('/', () => 'Hello')
.onBeforeHandle(() => console.log('log')) // Won't apply to the route above
// Correct: hook before route
.onBeforeHandle(() => console.log('log'))
.get('/', () => 'Hello')-
Ignoring encapsulation — Hooks in plugins don't affect the parent instance by default. Set the scope explicitly if you need cross-instance behavior.
-
Using
file()or static plugins on Cloudflare Worker — Cloudflare Workers lack thefsmodule; use Cloudflare's built-in static file serving instead. -
Eden Treaty version mismatch — The client and server must use the same version of Elysia, or type inference may be incorrect.
Further Reading
- Elysia Official Documentation — The authoritative reference
- Eden Treaty Documentation — End-to-end type-safe client
- Elysia GitHub — Source code and community discussions
- Bun Documentation — Understanding the Bun runtime
- TypeBox Documentation — Deep dive into Elysia.t's underlying type system
- Elysia Best Practices — Official recommended code organization