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.
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.
// Serverexport const app = new Elysia().get("/hi", () => "Hi Elysia").listen(3000);export type App = typeof app;// Clientimport { 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 string
Analogies
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.
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 onXxx methods, 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 automatically
Plugin System
Every Elysia instance is independent and can be composed with use(). This is Elysia's core composition pattern.
// Create a configurable pluginconst 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 pluginconst 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 onceconst app = new Elysia().use(ip).use(ip); // Won't execute again
Encapsulation & 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 constructorconst api = new Elysia({ prefix: "/api" }).get("/users", () => "Users");
Extending Context
Use state, decorate, derive, and resolve to extend the request context:
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.tsimport { 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 type
Client:
// client.tsimport { treaty } from "@elysiajs/eden";import type { App } from "./server";const api = treaty<App>("localhost:3000");// Fully type-safe with auto-completionconst { 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 statuses
Key mechanisms:
The Elysia instance type captures all route information (paths, methods, request schemas, response schemas).
typeof app elevates 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:
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 callsconst { 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 defaultnew 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
.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 onBeforeHandle to insert custom logic in the request pipeline.
Plugin Composition: Use use() to compose functionality, guard for 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 chainingconst app = new Elysia();app.state("version", 1);app.get("/", ({ store }) => store.version); // Type error!// Correct: using method chainingnew 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 the fs module; 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.