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 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.

// 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 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 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 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 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 type

Client:

// 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 statuses

Key mechanisms:

  1. The Elysia instance type captures all route information (paths, methods, request schemas, response schemas).
  2. typeof app elevates runtime code to compile-time types.
  3. Eden Treaty parses this type to construct a type-safe client API.
  4. 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):

FrameworkRuntimeAverage
ElysiaBun255,574
HonoBun203,937
FastifyNode60,322
ExpressNode15,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.ts

The 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 3000

Vercel deployment:

// src/index.ts
import { Elysia, t } from "elysia";
 
export default new Elysia().get("/", () => "Hello Vercel").listen(3000);
vc deploy

4. Key Notes (Cornell Note-taking)

Quick Reference Table

Cue/KeywordDetailed 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 ParametersURL ?key=value portion; accessed via query; values are strings by default
Request BodyAccessed via body; auto-parses JSON, FormData, URL-encoded
ResponseDirect return value; status() for status codes; set.headers for headers; redirect() for redirects
Validationt.Object(), t.String(), t.Number(), etc.; validates body, query, params, headers, cookie, response
LifecycleonRequestonParseonTransform → Validation → onBeforeHandle → Handler → onAfterHandleonMapResponseonErroronAfterResponse
Plugins.use() to compose instances; name property for deduplication; encapsulated by default
Scopelocal (default, isolated), scoped (extends to parent), global (everywhere)
GuardBulk-apply schemas and hooks; guard(schema, callback)
Groupgroup(prefix, callback) to organize route prefixes
Context Extensionstate (mutable global), decorate (immutable global), derive (pre-validation), resolve (post-validation)
CookieReactive 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 Treatytreaty<App>(url) creates a type-safe client; no code generation needed

Core API Reference

API / MethodPurposeExample
new Elysia()Create an instancenew 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 schemat.Object({ name: t.String() })
t.String()String typet.String({ format: 'email' })
t.Number()Number typet.Number({ minimum: 0 })
t.File()File typet.File({ type: 'image' })
t.Cookie({})Cookie schemat.Cookie({ session: t.String() })
t.Optional()Optional fieldt.Optional(t.String())
t.Union([])Union typet.Union([t.String(), t.Number()])
file(path)Return static file.get('/img', file('photo.webp'))
sse(data)SSE eventsse({ event: 'msg', data: 'hello' })
treaty<App>(url)Create Eden clienttreaty<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:

  1. Routing & Validation: Use t.Object() to define schemas, gaining automatic type inference and runtime validation.
  2. Lifecycle: Use hooks like onBeforeHandle to insert custom logic in the request pipeline.
  3. Plugin Composition: Use use() to compose functionality, guard for shared rules.
  4. Encapsulation & Scope: Understand the difference between local/scoped/global scopes.
  5. Eden Treaty: Use treaty<App>() to create an end-to-end type-safe client.

5. Review & Practice (SQ3R · Recite & Review)

Key Takeaways

  1. Elysia is a Bun-based TypeScript web framework that provides end-to-end type safety, formidable performance, and exceptional developer experience.

  2. Single Source of Truth is its core design — one schema serves runtime validation, TypeScript type inference, OpenAPI documentation, and client type synchronization.

  3. The Lifecycle system replaces traditional middleware with more granular request processing control. Hooks are encapsulated by default, with scope mechanisms to control their reach.

  4. Eden Treaty provides code-generation-free end-to-end type safety, with precise type inference for error status codes.

  5. Performance optimization comes from the triple acceleration of Bun runtime + static code analysis + AoT compilation.

  6. 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

  1. 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
  1. 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')
  1. Ignoring encapsulation — Hooks in plugins don't affect the parent instance by default. Set the scope explicitly if you need cross-instance behavior.

  2. Using file() or static plugins on Cloudflare Worker — Cloudflare Workers lack the fs module; use Cloudflare's built-in static file serving instead.

  3. Eden Treaty version mismatch — The client and server must use the same version of Elysia, or type inference may be incorrect.

Further Reading