Claude Skill

api-framework-elysia

Bun-native HTTP framework — routing, TypeBox validation, Eden Treaty, plugins, lifecycle hooks

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-framework-elysia_skills_api-framework-elysia-3a51ef5.zip · 18 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-framework-elysia/skills/api-framework-elysia
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

API Development with Elysia

Quick Guide: Elysia is a Bun-native HTTP framework with end-to-end type safety. Use method chaining (not separate statements) so TypeScript infers the full route tree. Import t from elysia for TypeBox validation. Export the app type (export type App = typeof app) for Eden Treaty clients. Use status() (not the deprecated error() function) for error responses with type narrowing.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST use method chaining on the Elysia instance -- separate .get() calls break type inference for Eden Treaty)

(You MUST use status() for error responses -- error() is deprecated since 1.3, prefer status())

(You MUST export the app type (export type App = typeof app) for Eden Treaty client generation)

</critical_requirements>


Auto-detection: Elysia, elysia, ElysiaJS, Eden Treaty, @elysiajs/eden, @elysiajs/openapi, t.Object, t.String, t.Number, t.File, TypeBox, .derive(), .decorate(), .guard(), .macro(), .ws(), onBeforeHandle, onAfterHandle, onRequest, treaty, bun:test

When to use:

  • Building APIs on Bun runtime with end-to-end type safety
  • Need RPC-style client with zero code generation (Eden Treaty)
  • TypeBox validation with AOT compilation (~18x faster than Zod on Bun)
  • Plugin-based architecture with automatic type propagation
  • WebSocket support with schema validation

When NOT to use:

  • Deploying to Node.js-only environments without Bun (use a Node-first framework)
  • Need OpenAPI-first design with createRoute patterns (other frameworks with Zod-OpenAPI integration are more mature for this)
  • Team already committed to Express/Fastify ecosystem

Key patterns covered:

  • Route definitions with method chaining and TypeBox validation
  • Plugin architecture with .use(), .derive(), .decorate(), .macro()
  • Scoping rules (local, scoped, global) and .guard()
  • End-to-end type safety with Eden Treaty
  • Lifecycle hooks (onRequest, onBeforeHandle, onAfterHandle, onError)
  • Error handling with custom error classes and status()
  • WebSocket with schema validation
  • Testing with bun:test and .handle() or Eden Treaty

Detailed Resources:




<red_flags>

RED FLAGS

High Priority:

  • Separate app.get() / app.post() calls instead of chaining -- breaks Eden Treaty type inference entirely
  • Using deprecated error() function instead of status() -- deprecated since Elysia 1.3
  • Not exporting type App = typeof app -- Eden Treaty client has no type information
  • Using as('plugin') -- removed in 1.3+, use as('scoped') instead

Medium Priority:

  • Importing patterns from other HTTP frameworks -- Elysia has its own routing API and conventions
  • Using t.Object() without named constants for limits/lengths -- magic numbers in validation schemas
  • Not providing name on plugin instances -- causes duplicate registration in complex app trees
  • .derive() or lifecycle hooks without { as: 'scoped' } or { as: 'global' } when parent routes need them -- hooks are local-scoped by default

Gotchas & Edge Cases:

  • params are strings by default -- use t.Number() in the schema to coerce path params to numbers
  • .handle() in tests requires a fully qualified URL (http://localhost/path), NOT a path fragment (/path)
  • .guard() group standalone mode is default in 1.4+ -- guard schemas merge with route schemas instead of overwriting
  • TypeBox t.File() auto-detects multipart/form-data content type -- no need to set headers manually
  • t.Files() (plural) for multiple file uploads, t.File() for single
  • Eden Treaty dynamic path params use function syntax: api.user({ id: 1 }).get() not api.user[1].get()
  • When Eden Treaty response has status >= 300, data is always null and error has the value
  • Lifecycle hooks only apply to routes registered AFTER the hook -- order of .on*() and route definitions matters
  • onError receives a code string, not a status number -- switch on code for type narrowing
  • Cookies parse as JSON automatically if the value looks like JSON (1.3+ behavior)
  • await app.modules is required in tests when using lazy-loaded plugins (import('./plugin'))

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md

(You MUST use method chaining on the Elysia instance -- separate .get() calls break type inference for Eden Treaty)

(You MUST use status() for error responses -- error() is deprecated since 1.3, prefer status())

(You MUST export the app type (export type App = typeof app) for Eden Treaty client generation)

Failure to follow these rules will break end-to-end type safety and Eden Treaty client generation.

</critical_reminders>

Files (skills)
  • examples
    • core.md 9.2 KB
      # Elysia - Core Examples
      
      > Essential patterns for Elysia route setup, validation, and plugin composition. See [SKILL.md](../SKILL.md) for core concepts and [reference.md](../reference.md) for decision frameworks.
      
      **Additional Examples:**
      
      - [eden-treaty.md](eden-treaty.md) - End-to-end type-safe client
      - [lifecycle-errors.md](lifecycle-errors.md) - Lifecycle hooks, error handling, custom errors
      - [websocket-testing.md](websocket-testing.md) - WebSocket patterns and unit testing
      
      ---
      
      ## Pattern 1: Modular App with Plugins
      
      ### Good Example - Method-Chained Modular Setup
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      // Each module is its own Elysia instance with a name and prefix
      const userRoutes = new Elysia({ name: "user", prefix: "/user" })
        .get("/", () => ({ users: [] }))
        .get("/:id", ({ params: { id } }) => ({ id }), {
          params: t.Object({
            id: t.Number(),
          }),
        })
        .post("/", ({ body }) => ({ created: body }), {
          body: t.Object({
            name: t.String(),
            email: t.String({ format: "email" }),
          }),
        });
      
      const healthRoutes = new Elysia({ name: "health", prefix: "/health" }).get(
        "/",
        () => ({ status: "ok" }),
      );
      
      // Main app composes plugins via .use()
      const app = new Elysia().use(userRoutes).use(healthRoutes).listen(3000);
      
      // REQUIRED: Export type for Eden Treaty clients
      export type App = typeof app;
      ```
      
      **Why good:** `name` prevents duplicate registration, `prefix` scopes routes, method chaining preserves type inference, `export type App` enables Eden Treaty
      
      ### Bad Example - Separate Calls and No Plugin Names
      
      ```typescript
      // BAD: Separate calls break type inference
      import { Elysia } from "elysia";
      
      const app = new Elysia();
      
      // BAD: Each call is separate -- types don't chain
      app.get("/user", () => ({ users: [] }));
      app.post("/user", ({ body }) => body);
      
      // BAD: No name -- duplicate registration possible
      const auth = new Elysia();
      auth.derive(({ headers }) => ({ user: decodeToken(headers.authorization) }));
      
      app.use(auth);
      
      // BAD: Default export
      export default app;
      ```
      
      **Why bad:** separate route calls break Eden Treaty type inference, unnamed plugins can register multiple times, default export violates project conventions
      
      ---
      
      ## Pattern 2: TypeBox Validation
      
      ### Good Example - Complete Request/Response Validation
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const MIN_NAME_LENGTH = 1;
      const MAX_NAME_LENGTH = 100;
      const MIN_AGE = 0;
      const MAX_AGE = 150;
      const DEFAULT_PAGE = 1;
      const DEFAULT_LIMIT = 20;
      
      const app = new Elysia()
        .post(
          "/user",
          ({ body }) => ({
            id: crypto.randomUUID(),
            ...body,
          }),
          {
            body: t.Object({
              name: t.String({
                minLength: MIN_NAME_LENGTH,
                maxLength: MAX_NAME_LENGTH,
                error: "Name must be 1-100 characters",
              }),
              email: t.String({ format: "email", error: "Invalid email format" }),
              age: t.Optional(
                t.Number({
                  minimum: MIN_AGE,
                  maximum: MAX_AGE,
                }),
              ),
            }),
            response: {
              200: t.Object({
                id: t.String({ format: "uuid" }),
                name: t.String(),
                email: t.String(),
                age: t.Optional(t.Number()),
              }),
              400: t.Object({
                error: t.String(),
              }),
            },
          },
        )
        .get("/user", ({ query }) => ({ users: [], page: query.page }), {
          query: t.Object({
            page: t.Optional(t.Number({ default: DEFAULT_PAGE })),
            limit: t.Optional(t.Number({ default: DEFAULT_LIMIT })),
            search: t.Optional(t.String()),
          }),
        });
      ```
      
      **Why good:** named constants for all limits, `error` property on schema fields gives custom validation messages, response schemas per status code enable type narrowing, `t.Optional()` for non-required fields
      
      ### Bad Example - No Validation
      
      ```typescript
      // BAD: No validation at all
      new Elysia().post("/user", ({ body }) => {
        // body is unknown -- runtime crashes on bad input
        const name = body.name; // TypeError if body is not an object
        return { id: 1, name };
      });
      ```
      
      **Why bad:** no TypeBox schema means no validation, no type inference, crashes on malformed input
      
      ---
      
      ## Pattern 3: .derive() and .decorate()
      
      ### Good Example - Per-Request vs Singleton Context
      
      ```typescript
      import { Elysia } from "elysia";
      
      // Singleton service -- same instance for all requests
      class DatabaseClient {
        query(sql: string) {
          return [];
        }
      }
      const db = new DatabaseClient();
      
      const app = new Elysia({ name: "app" })
        // .decorate() for singletons (same for all requests)
        .decorate("db", db)
        // .derive() for per-request computed values
        .derive(({ headers }) => {
          const token = headers.authorization?.replace("Bearer ", "");
          return {
            userId: token ? decodeToken(token) : null,
          };
        })
        .get("/profile", ({ userId, db }) => {
          if (!userId) return { error: "Unauthorized" };
          return db.query(`SELECT * FROM users WHERE id = ${userId}`);
        });
      ```
      
      **Why good:** `.decorate()` for the DB client (shared singleton), `.derive()` for userId (computed per request from headers), both are type-safe in route handlers
      
      ### Bad Example - Using .decorate() for Per-Request Data
      
      ```typescript
      // BAD: decorate runs once, not per request
      new Elysia()
        .decorate("currentUser", null) // Same null for ALL requests
        .get("/profile", ({ currentUser }) => currentUser); // Always null
      ```
      
      **Why bad:** `.decorate()` evaluates once at setup time, all requests share the same value
      
      ---
      
      ## Pattern 4: Guard for Route Groups
      
      ### Good Example - Scoped Validation with Guard
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const app = new Elysia()
        // Public routes -- no auth required
        .get("/", () => "welcome")
        .get("/health", () => ({ status: "ok" }))
        // Protected routes -- all require auth header
        .guard(
          {
            headers: t.Object({
              authorization: t.TemplateLiteral("Bearer ${string}"),
            }),
          },
          (app) =>
            app
              .get("/dashboard", ({ headers }) => ({
                message: `Authenticated: ${headers.authorization}`,
              }))
              .get("/settings", () => ({ theme: "dark" }))
              .post("/settings", ({ body }) => body, {
                body: t.Object({
                  theme: t.Union([t.Literal("dark"), t.Literal("light")]),
                }),
              }),
        );
      ```
      
      **Why good:** auth validation defined once for the group, public routes outside guard are unaffected, route-specific body schema merges with guard schema (standalone mode in 1.4+)
      
      ---
      
      ## Pattern 5: .group() with Prefix
      
      ### Good Example - Versioned API Groups
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const app = new Elysia()
        .group("/api/v1", (app) =>
          app
            .get("/users", () => ({ version: 1, users: [] }))
            .post("/users", ({ body }) => body, {
              body: t.Object({ name: t.String() }),
            }),
        )
        .group(
          "/api/v2",
          {
            // Guard schema applied to all routes in this group
            headers: t.Object({
              "x-api-key": t.String(),
            }),
          },
          (app) =>
            app
              .get("/users", () => ({ version: 2, users: [] }))
              .post("/users", ({ body }) => body, {
                body: t.Object({
                  name: t.String(),
                  metadata: t.Optional(t.Record(t.String(), t.String())),
                }),
              }),
        );
      ```
      
      **Why good:** `.group()` with guard schema combines prefix + validation in one call, v2 group requires API key while v1 does not
      
      ---
      
      ## Pattern 6: Reference Models
      
      ### Good Example - Reusable Named Schemas
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const MIN_PASSWORD_LENGTH = 8;
      
      // Define reusable models once
      const app = new Elysia()
        .model({
          signIn: t.Object({
            username: t.String(),
            password: t.String({ minLength: MIN_PASSWORD_LENGTH }),
          }),
          user: t.Object({
            id: t.String(),
            username: t.String(),
            createdAt: t.String({ format: "date-time" }),
          }),
        })
        // Reference by name -- string key maps to registered model
        .post("/sign-in", ({ body }) => authenticate(body), {
          body: "signIn",
          response: "user",
        })
        .post("/sign-up", ({ body }) => createUser(body), {
          body: "signIn",
          response: "user",
        });
      ```
      
      **Why good:** `.model()` registers reusable schemas, reference by string key in route options, single source of truth for shared shapes, eliminates schema duplication across routes
      
      ---
      
      ## Pattern 7: Macro for Cross-Cutting Concerns
      
      ### Good Example - Auth Macro with Schema
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const HTTP_UNAUTHORIZED = 401;
      
      const authPlugin = new Elysia({ name: "auth" }).macro({
        isSignedIn: {
          // Macro adds cookie validation automatically
          cookie: t.Object({
            session: t.String(),
          }),
          // resolve runs before handler, can short-circuit
          resolve({ cookie: { session }, status }) {
            if (!session.value) {
              return status(HTTP_UNAUTHORIZED, "Unauthorized");
            }
            return { userId: decodeSession(session.value) };
          },
        },
      });
      
      const app = new Elysia()
        .use(authPlugin)
        .get("/public", () => "hello")
        .get(
          "/profile",
          ({ userId }) => ({ userId }), // userId injected by macro
          { isSignedIn: true }, // Enable the macro for this route
        );
      ```
      
      **Why good:** macros encapsulate cross-cutting concerns (auth, rate limiting) as declarative flags on routes, schema and resolve are bundled together, `isSignedIn: true` is self-documenting in route options
      
    • eden-treaty.md 5.5 KB
      # Elysia - Eden Treaty Examples
      
      > End-to-end type-safe client patterns. See [core.md](core.md) for route setup patterns.
      
      **Prerequisites**: Understand method chaining and `export type App` from core examples first.
      
      ---
      
      ## Pattern 1: Basic Eden Treaty Setup
      
      ### Good Example - Type-Safe Client
      
      ```typescript
      // server.ts
      import { Elysia, t } from "elysia";
      
      const app = new Elysia()
        .get(
          "/user/:id",
          ({ params: { id } }) => ({
            id,
            name: "Alice",
            email: "alice@example.com",
          }),
          {
            params: t.Object({ id: t.Number() }),
          },
        )
        .post(
          "/user",
          ({ body }) => ({
            id: crypto.randomUUID(),
            ...body,
          }),
          {
            body: t.Object({
              name: t.String(),
              email: t.String({ format: "email" }),
            }),
          },
        )
        .listen(3000);
      
      export type App = typeof app;
      ```
      
      ```typescript
      // client.ts
      import { treaty } from "@elysiajs/eden";
      import type { App } from "./server";
      
      const api = treaty<App>("localhost:3000");
      
      // GET /user/:id -- dynamic params use function syntax
      const { data, error } = await api.user({ id: 42 }).get();
      
      if (error) {
        // error is typed based on route's error responses
        console.error(error);
        return;
      }
      // data is typed as { id: number; name: string; email: string }
      console.log(data.name);
      
      // POST /user -- body passed as argument
      const { data: created } = await api.user.post({
        name: "Bob",
        email: "bob@example.com",
      });
      ```
      
      **Why good:** zero code generation, paths and methods are fully typed via dot notation, dynamic params use function syntax (not bracket notation), `data`/`error` destructuring provides type narrowing
      
      ### Bad Example - Losing Type Safety
      
      ```typescript
      // BAD: Using fetch instead of treaty
      const response = await fetch("http://localhost:3000/user/42");
      const data = await response.json(); // data is 'any'
      
      // BAD: Wrong dynamic param syntax
      const { data } = await api.user[42].get(); // This syntax doesn't work!
      ```
      
      **Why bad:** raw `fetch` loses all type information, bracket notation doesn't work for dynamic params in Eden Treaty
      
      ---
      
      ## Pattern 2: Response Handling
      
      ### Good Example - Error/Data Type Narrowing
      
      ```typescript
      import { treaty } from "@elysiajs/eden";
      import type { App } from "./server";
      
      const api = treaty<App>("localhost:3000");
      
      async function getUser(id: number) {
        const { data, error, status } = await api.user({ id }).get();
      
        // When status >= 300: data is null, error has the value
        // When status < 300: error is null, data has the value
        if (error) {
          switch (error.status) {
            case 404:
              console.log("User not found");
              break;
            case 500:
              console.log("Server error:", error.value);
              break;
          }
          return null;
        }
      
        // TypeScript knows data is non-null here
        return data;
      }
      ```
      
      **Why good:** Eden Treaty splits responses into `data` (success) and `error` (failure) with proper type narrowing, `error.status` enables exhaustive handling by status code
      
      ---
      
      ## Pattern 3: File Upload via Treaty
      
      ### Good Example - Multipart Upload
      
      ```typescript
      // server.ts
      import { Elysia, t } from "elysia";
      
      const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
      
      const app = new Elysia().post(
        "/upload",
        ({ body }) => ({
          name: body.file.name,
          size: body.file.size,
        }),
        {
          body: t.Object({
            file: t.File({ maxSize: MAX_FILE_SIZE_BYTES }),
            description: t.Optional(t.String()),
          }),
        },
      );
      
      export type App = typeof app;
      ```
      
      ```typescript
      // client.ts
      import { treaty } from "@elysiajs/eden";
      import type { App } from "./server";
      
      const api = treaty<App>("localhost:3000");
      
      // Eden Treaty handles FormData conversion automatically
      const { data } = await api.upload.post({
        file: new File(["content"], "readme.txt"),
        description: "A text file",
      });
      ```
      
      **Why good:** `t.File()` auto-detects multipart content type, Eden Treaty converts the object to FormData automatically, file validation (max size) runs server-side
      
      ---
      
      ## Pattern 4: Treaty with Headers and Auth
      
      ### Good Example - Custom Headers per Request
      
      ```typescript
      import { treaty } from "@elysiajs/eden";
      import type { App } from "./server";
      
      // Set default headers for all requests
      const api = treaty<App>("localhost:3000", {
        headers: {
          "x-api-version": "1",
        },
      });
      
      // Override headers per request using $headers
      const { data } = await api.profile.get({
        $headers: {
          authorization: `Bearer ${token}`,
        },
      });
      ```
      
      **Why good:** treaty config sets baseline headers, `$headers` in request options overrides per-call, headers are type-checked if the route has a `headers` schema
      
      ---
      
      ## Pattern 5: Treaty Unit Testing (No Network)
      
      ### Good Example - Direct Instance Testing
      
      ```typescript
      import { describe, expect, it } from "bun:test";
      import { Elysia, t } from "elysia";
      import { treaty } from "@elysiajs/eden";
      
      const app = new Elysia()
        .get("/hello", () => "hi")
        .get("/user/:id", ({ params: { id } }) => ({ id, name: "Test" }), {
          params: t.Object({ id: t.Number() }),
        });
      
      // Pass Elysia instance directly -- no HTTP server started
      const api = treaty(app);
      
      describe("User API", () => {
        it("returns greeting", async () => {
          const { data } = await api.hello.get();
          expect(data).toBe("hi");
        });
      
        it("returns user by id", async () => {
          const { data } = await api.user({ id: 1 }).get();
          expect(data?.name).toBe("Test");
        });
      });
      ```
      
      **Why good:** passing the Elysia instance directly to `treaty()` bypasses HTTP -- tests run without starting a server, full type safety and lifecycle hooks still execute, faster than network-based tests
      
    • lifecycle-errors.md 9 KB
      # Elysia - Lifecycle Hooks & Error Handling Examples
      
      > Lifecycle hooks, custom errors, and error handling patterns. See [core.md](core.md) for route setup patterns.
      
      **Prerequisites**: Understand route definitions and `.derive()` from core examples first.
      
      ---
      
      ## Lifecycle Hook Order
      
      Elysia processes requests through these phases in order:
      
      1. **onRequest** -- First hook, runs for every request (rate limiting, CORS)
      2. **onParse** -- Body parsing
      3. **onTransform** -- Mutate context before validation (coerce types)
      4. **onBeforeHandle** -- After validation, before handler (auth checks)
      5. **Route Handler** -- Your business logic
      6. **onAfterHandle** -- After handler, before response (add headers)
      7. **onMapResponse** -- Transform response shape
      8. **onError** -- Runs ONLY when an error is thrown anywhere
      9. **onAfterResponse** -- After response sent to client (cleanup, analytics)
      
      **Critical:** Hooks only apply to routes registered AFTER the hook. Order matters.
      
      ---
      
      ## Pattern 1: onBeforeHandle for Auth
      
      ### Good Example - Auth Guard via Lifecycle Hook
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const HTTP_UNAUTHORIZED = 401;
      
      const authPlugin = new Elysia({ name: "auth" })
        .derive({ as: "scoped" }, ({ headers }) => ({
          bearerToken: headers.authorization?.replace("Bearer ", "") ?? null,
        }))
        .onBeforeHandle({ as: "scoped" }, ({ bearerToken, status }) => {
          if (!bearerToken) {
            // Returning a value short-circuits the handler
            return status(HTTP_UNAUTHORIZED, {
              error: "Missing Authorization header",
            });
          }
        });
      
      const app = new Elysia()
        .get("/public", () => "no auth needed")
        .use(authPlugin) // Auth applies to routes registered AFTER this
        .get("/protected", ({ bearerToken }) => ({
          message: `Authenticated with token: ${bearerToken}`,
        }));
      ```
      
      **Why good:** `{ as: "scoped" }` propagates the hook to the parent (not just within the plugin), returning a value from `onBeforeHandle` short-circuits the handler, public routes defined before `.use(authPlugin)` are unaffected
      
      ### Bad Example - Local Scope Doesn't Propagate
      
      ```typescript
      // BAD: Hook stays local -- parent routes never see it
      const HTTP_UNAUTHORIZED = 401;
      
      const authPlugin = new Elysia({ name: "auth" }).onBeforeHandle(
        ({ headers, status }) => {
          // This ONLY runs for routes inside authPlugin, not parent routes
          if (!headers.authorization)
            return status(HTTP_UNAUTHORIZED, "Unauthorized");
        },
      );
      ```
      
      **Why bad:** default `local` scope means the hook only applies inside the plugin -- routes in the parent that `.use(authPlugin)` will NOT run this hook
      
      ---
      
      ## Pattern 2: onRequest for Rate Limiting
      
      ### Good Example - Early Request Interception
      
      ```typescript
      import { Elysia } from "elysia";
      
      const RATE_LIMIT_WINDOW_MS = 60_000;
      const MAX_REQUESTS_PER_WINDOW = 100;
      const HTTP_TOO_MANY_REQUESTS = 429;
      
      const rateLimiter = new Map<string, { count: number; resetAt: number }>();
      
      const rateLimitPlugin = new Elysia({ name: "rate-limit" }).onRequest(
        { as: "global" },
        ({ request, server, status }) => {
          const ip = server?.requestIP(request)?.address ?? "unknown";
          const now = Date.now();
          const entry = rateLimiter.get(ip);
      
          if (entry && now < entry.resetAt) {
            if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
              return status(HTTP_TOO_MANY_REQUESTS, "Rate limit exceeded");
            }
            entry.count++;
          } else {
            rateLimiter.set(ip, {
              count: 1,
              resetAt: now + RATE_LIMIT_WINDOW_MS,
            });
          }
        },
      );
      ```
      
      **Why good:** `onRequest` is the first lifecycle hook (runs before parsing), `{ as: "global" }` applies to ALL routes in the entire app, rate limiting at the earliest possible point avoids unnecessary work
      
      ---
      
      ## Pattern 3: onAfterHandle for Response Headers
      
      ### Good Example - Add Custom Headers to Responses
      
      ```typescript
      import { Elysia } from "elysia";
      
      const CACHE_MAX_AGE_SECONDS = 3600;
      
      const app = new Elysia()
        .onAfterHandle({ as: "global" }, ({ set }) => {
          set.headers["x-powered-by"] = "Elysia";
          set.headers["x-request-id"] = crypto.randomUUID();
        })
        .get("/api/data", ({ set }) => {
          // Route-specific headers
          set.headers["cache-control"] = `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
          return { data: "cached content" };
        });
      ```
      
      **Why good:** `onAfterHandle` runs after the handler completes, ideal for response decoration, `set.headers` is the mutable response header object
      
      ---
      
      ## Pattern 4: Custom Error Classes
      
      ### Good Example - Typed Custom Errors
      
      ```typescript
      import { Elysia } from "elysia";
      
      const HTTP_BAD_REQUEST = 400;
      const HTTP_NOT_FOUND = 404;
      const HTTP_FORBIDDEN = 403;
      
      class ValidationError extends Error {
        status = HTTP_BAD_REQUEST;
        constructor(
          public field: string,
          message: string,
        ) {
          super(message);
        }
      }
      
      class NotFoundError extends Error {
        status = HTTP_NOT_FOUND;
        constructor(public resource: string) {
          super(`${resource} not found`);
        }
      }
      
      class ForbiddenError extends Error {
        status = HTTP_FORBIDDEN;
        constructor() {
          super("Insufficient permissions");
        }
      }
      
      const app = new Elysia()
        .error({
          ValidationError,
          NotFoundError,
          ForbiddenError,
        })
        .onError(({ code, error, status }) => {
          // code is narrowed to string union of registered error names
          switch (code) {
            case "ValidationError":
              // error is typed as ValidationError here
              return status(HTTP_BAD_REQUEST, {
                error: "validation_error",
                field: error.field,
                message: error.message,
              });
      
            case "NotFoundError":
              return status(HTTP_NOT_FOUND, {
                error: "not_found",
                resource: error.resource,
              });
      
            case "ForbiddenError":
              return status(HTTP_FORBIDDEN, {
                error: "forbidden",
                message: error.message,
              });
      
            case "VALIDATION":
              // Built-in Elysia validation error
              return status(HTTP_BAD_REQUEST, {
                error: "validation_error",
                message: "Request validation failed",
              });
      
            case "NOT_FOUND":
              // Built-in Elysia 404
              return status(HTTP_NOT_FOUND, {
                error: "not_found",
                message: "Route not found",
              });
          }
        })
        .get("/user/:id", ({ params: { id } }) => {
          const user = findUser(id);
          if (!user) throw new NotFoundError("User");
          return user;
        });
      ```
      
      **Why good:** `.error()` registers custom error classes for type-safe `code` narrowing in `onError`, `status` property on error class sets the default HTTP status, built-in codes (`VALIDATION`, `NOT_FOUND`, `PARSE`, `INTERNAL_SERVER_ERROR`) coexist with custom ones
      
      ---
      
      ## Pattern 5: onTransform for Input Coercion
      
      ### Good Example - Coerce Before Validation
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const app = new Elysia()
        .onTransform(({ params }) => {
          // Coerce string params to numbers before validation runs
          if (params && "id" in params) {
            const parsed = Number(params.id);
            if (!Number.isNaN(parsed)) {
              params.id = parsed;
            }
          }
        })
        .get("/user/:id", ({ params: { id } }) => ({ id }), {
          params: t.Object({ id: t.Number() }),
        });
      ```
      
      **Why good:** `onTransform` runs BEFORE validation, so coerced values pass the `t.Number()` check, avoids validation failures for URL params that are always strings
      
      > **Note:** Elysia 1.3+ has automatic type coercion for query/params when using TypeBox schemas, so manual `onTransform` coercion is often unnecessary. Use it for custom coercion logic only.
      
      ---
      
      ## Pattern 6: onAfterResponse for Cleanup
      
      ### Good Example - Logging and Analytics
      
      ```typescript
      import { Elysia } from "elysia";
      
      const app = new Elysia()
        .onAfterResponse({ as: "global" }, ({ request, set }) => {
          // Runs AFTER the response is sent to the client
          // Safe for slow operations -- doesn't affect response time
          console.log(
            JSON.stringify({
              method: request.method,
              path: new URL(request.url).pathname,
              status: set.status,
              timestamp: new Date().toISOString(),
            }),
          );
        })
        .get("/", () => "hello");
      ```
      
      **Why good:** `onAfterResponse` runs after the client receives the response, expensive logging or analytics don't add latency, `{ as: "global" }` captures all routes
      
      ---
      
      ## Pattern 7: Throwing vs Returning Status
      
      ### Important Distinction
      
      ```typescript
      import { Elysia } from "elysia";
      
      const HTTP_UNAUTHORIZED = 401;
      const HTTP_TEAPOT = 418;
      
      const app = new Elysia()
        .onError(({ code }) => {
          console.log("onError triggered:", code);
        })
        // THROWING status triggers onError
        .get("/throw", ({ status }) => {
          throw status(HTTP_TEAPOT, "I'm a teapot");
          // onError WILL run with code "UNKNOWN"
        })
        // RETURNING status does NOT trigger onError
        .get("/return", ({ status }) => {
          return status(HTTP_UNAUTHORIZED, "Not authorized");
          // onError will NOT run -- response sent directly
        });
      ```
      
      **Why important:** throwing `status()` routes through `onError` for centralized handling, returning `status()` bypasses `onError` entirely and sends the response directly -- choose based on whether you want centralized error handling for this case
      
    • websocket-testing.md 7.2 KB
      # Elysia - WebSocket & Testing Examples
      
      > WebSocket patterns and unit testing approaches. See [core.md](core.md) for route setup patterns.
      
      **Prerequisites**: Understand route definitions and validation from core examples first.
      
      ---
      
      ## Pattern 1: Basic WebSocket
      
      ### Good Example - Echo Server with Validation
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const app = new Elysia().ws("/ws", {
        // Validate incoming messages with TypeBox
        body: t.Object({
          type: t.Union([t.Literal("chat"), t.Literal("ping")]),
          content: t.String(),
        }),
        // Validate query params on initial connection
        query: t.Object({
          room: t.String(),
        }),
        open(ws) {
          const { room } = ws.data.query;
          ws.subscribe(room);
          ws.send({ event: "joined", room });
        },
        message(ws, { type, content }) {
          // message is already validated and typed
          if (type === "ping") {
            ws.send({ event: "pong" });
            return;
          }
          // Publish to all subscribers in the room
          const { room } = ws.data.query;
          ws.publish(room, { event: "message", content, room });
        },
        close(ws) {
          const { room } = ws.data.query;
          ws.unsubscribe(room);
        },
      });
      ```
      
      **Why good:** `body` schema validates every incoming message (invalid messages are rejected), `query` validates connection params, `ws.subscribe`/`ws.publish` for pub/sub channels, destructured `message` params are fully typed
      
      ### Bad Example - No Validation
      
      ```typescript
      // BAD: No message validation
      new Elysia().ws("/ws", {
        message(ws, message) {
          // message is unknown -- could be anything
          ws.send(JSON.stringify({ echo: message }));
        },
      });
      ```
      
      **Why bad:** no TypeBox schema means unvalidated input, `message` type is unknown, crashes on unexpected payloads
      
      ---
      
      ## Pattern 2: WebSocket with Derive
      
      ### Good Example - Auth in WebSocket
      
      ```typescript
      import { Elysia, t } from "elysia";
      
      const HTTP_UNAUTHORIZED = 401;
      
      const wsApp = new Elysia()
        .derive(({ headers, status }) => {
          const token = headers.authorization?.replace("Bearer ", "");
          if (!token) return status(HTTP_UNAUTHORIZED, "Missing auth");
          return { userId: decodeToken(token) };
        })
        .ws("/notifications", {
          body: t.Object({
            type: t.Literal("subscribe"),
            channel: t.String(),
          }),
          open(ws) {
            // userId is available from derive
            const { userId } = ws.data;
            ws.subscribe(`user:${userId}`);
          },
          message(ws, { channel }) {
            const { userId } = ws.data;
            ws.subscribe(`${channel}:${userId}`);
          },
        });
      ```
      
      **Why good:** `.derive()` runs before the WebSocket upgrade, auth happens at connection time (not per message), `ws.data` carries the derived context
      
      ---
      
      ## Pattern 3: Unit Testing with .handle()
      
      ### Good Example - Testing Without Network
      
      ```typescript
      import { describe, expect, it } from "bun:test";
      import { Elysia, t } from "elysia";
      
      const HTTP_NOT_FOUND = 404;
      const HTTP_UNPROCESSABLE_ENTITY = 422;
      
      // Create app instance (no .listen() needed for tests)
      const app = new Elysia()
        .get("/hello", () => "world")
        .post("/user", ({ body }) => ({ id: 1, ...body }), {
          body: t.Object({
            name: t.String(),
            email: t.String({ format: "email" }),
          }),
        })
        .get(
          "/user/:id",
          ({ params: { id }, status }) => {
            if (id === 0) return status(HTTP_NOT_FOUND, { error: "Not found" });
            return { id, name: "Alice" };
          },
          {
            params: t.Object({ id: t.Number() }),
          },
        );
      
      describe("API Routes", () => {
        it("GET /hello returns world", async () => {
          // IMPORTANT: Must use fully qualified URL
          const response = await app.handle(new Request("http://localhost/hello"));
          const text = await response.text();
          expect(text).toBe("world");
        });
      
        it("POST /user creates user", async () => {
          const response = await app.handle(
            new Request("http://localhost/user", {
              method: "POST",
              headers: { "content-type": "application/json" },
              body: JSON.stringify({
                name: "Alice",
                email: "alice@example.com",
              }),
            }),
          );
          const data = await response.json();
          expect(data.id).toBe(1);
          expect(data.name).toBe("Alice");
        });
      
        it("POST /user rejects invalid email", async () => {
          const response = await app.handle(
            new Request("http://localhost/user", {
              method: "POST",
              headers: { "content-type": "application/json" },
              body: JSON.stringify({
                name: "Alice",
                email: "not-an-email",
              }),
            }),
          );
          expect(response.status).toBe(HTTP_UNPROCESSABLE_ENTITY);
        });
      
        it("GET /user/0 returns 404", async () => {
          const response = await app.handle(new Request("http://localhost/user/0"));
          expect(response.status).toBe(HTTP_NOT_FOUND);
        });
      });
      ```
      
      **Why good:** `.handle()` processes the full lifecycle (validation, hooks, handler) without starting an HTTP server, fully qualified URLs are required (not path fragments), tests run fast with no network overhead
      
      > **Note:** `.handle()` requires a fully qualified URL (`http://localhost/path`), not a path fragment (`/path`). See [reference.md](../reference.md) for the anti-pattern.
      
      For type-safe testing with Eden Treaty (no HTTP server needed), see [eden-treaty.md Pattern 5](eden-treaty.md#pattern-5-treaty-unit-testing-no-network).
      
      ---
      
      ## Pattern 4: Testing with Async Plugins
      
      ### Good Example - Awaiting Lazy-Loaded Modules
      
      ```typescript
      import { describe, expect, it, beforeAll } from "bun:test";
      import { Elysia } from "elysia";
      
      // App with lazy-loaded plugin
      const app = new Elysia()
        .use(import("./user-plugin"))
        .use(import("./health-plugin"));
      
      describe("App with lazy plugins", () => {
        beforeAll(async () => {
          // REQUIRED: Wait for all async plugins to resolve
          await app.modules;
        });
      
        it("responds to lazy-loaded routes", async () => {
          const response = await app.handle(new Request("http://localhost/user/1"));
          expect(response.status).toBe(200);
        });
      });
      ```
      
      **Why good:** `await app.modules` resolves all lazy-loaded plugins before tests run, without this line, routes from async plugins may not be registered yet
      
      ---
      
      ## Pattern 5: Testing Lifecycle Hooks
      
      ### Good Example - Verifying Hook Behavior
      
      ```typescript
      import { describe, expect, it } from "bun:test";
      import { Elysia, t } from "elysia";
      
      const HTTP_UNAUTHORIZED = 401;
      
      const app = new Elysia()
        .derive(({ headers }) => ({
          apiKey: headers["x-api-key"] ?? null,
        }))
        .onBeforeHandle(({ apiKey, status }) => {
          if (!apiKey) return status(HTTP_UNAUTHORIZED, "Missing API key");
        })
        .get("/protected", ({ apiKey }) => ({ key: apiKey }));
      
      describe("Auth lifecycle", () => {
        it("rejects requests without API key", async () => {
          const response = await app.handle(
            new Request("http://localhost/protected"),
          );
          expect(response.status).toBe(HTTP_UNAUTHORIZED);
        });
      
        it("accepts requests with API key", async () => {
          const response = await app.handle(
            new Request("http://localhost/protected", {
              headers: { "x-api-key": "valid-key" },
            }),
          );
          expect(response.status).toBe(200);
          const data = await response.json();
          expect(data.key).toBe("valid-key");
        });
      });
      ```
      
      **Why good:** `.handle()` executes the full lifecycle including `.derive()` and `onBeforeHandle`, tests verify both the auth rejection and success paths, named constant for status code
      
  • reference.md 8 KB
    # Elysia Quick Reference
    
    > Decision frameworks, anti-patterns, and production checklist for Elysia. Referenced from [SKILL.md](SKILL.md).
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### When to Use Elysia
    
    - Building on Bun runtime with type safety as the top priority
    - Need RPC-style client with zero code generation (Eden Treaty)
    - High-performance validation with TypeBox AOT compilation
    - WebSocket support with schema validation baked in
    - Plugin-based architecture with automatic type propagation
    
    ### When NOT to Use Elysia
    
    - Node.js-only deployment (Elysia is Bun-first; Node adapter exists but is secondary)
    - OpenAPI-first design workflow (Zod-OpenAPI integration is more mature in other frameworks)
    - Team already invested in Express/Fastify middleware ecosystem
    - Need broad community plugin ecosystem (Express/Fastify have more third-party middleware)
    
    ### TypeBox vs Standard Schema (Zod, Valibot, etc.)
    
    **Use TypeBox (`t` from `elysia`):**
    
    - Default choice for Bun projects -- AOT compilation is ~18x faster than Zod
    - Full OpenAPI generation support with `@elysiajs/openapi`
    - Best type inference with Elysia's internal types
    
    **Use Standard Schema (Zod, Valibot, ArkType):**
    
    - Sharing schemas between Bun backend and non-Bun frontend
    - Team already has Zod schemas and doesn't want to rewrite
    - Need Zod ecosystem (zod-to-json-schema, shared schema libraries)
    
    **Mix validators in same route (1.4+):**
    
    - Elysia 1.4+ supports different validators per field (params: Zod, body: Valibot)
    - Standalone mode merges schemas rather than overwriting
    
    ### Scope Decision
    
    | Scope    | Child | Current | Parent | Main | Use When                                     |
    | -------- | ----- | ------- | ------ | ---- | -------------------------------------------- |
    | `local`  | yes   | yes     | no     | no   | Default. Plugin-internal hooks and state.    |
    | `scoped` | yes   | yes     | yes    | no   | Hooks that parent plugins need but not main. |
    | `global` | yes   | yes     | yes    | yes  | Hooks that EVERY route in the app must run.  |
    
    ### State vs Decorate vs Derive
    
    **`.state(key, value)`** -- Global mutable store. Shared across all requests. Access via `store`.
    
    **`.decorate(key, value)`** -- Singleton value on context. Should NOT be mutated. Use for services, utilities, DB clients.
    
    **`.derive(handler)`** -- Per-request computed value. Runs at transform phase (before validation). Can access `headers`, `query`, `body`.
    
    **`.resolve(handler)`** -- Like `.derive()` but runs after validation. Access validated request data.
    
    **Rule of thumb:** If it's the same for every request, use `.decorate()`. If it changes per request, use `.derive()` or `.resolve()`.
    
    ### Eden Treaty vs REST Client
    
    **Use Eden Treaty:**
    
    - Full-stack TypeScript monorepo
    - Same Elysia version on client and server
    - Want end-to-end type safety without code generation
    - Internal APIs consumed by your own frontend
    
    **Use REST/OpenAPI Client:**
    
    - Multi-language clients (Python, Go, etc.)
    - External consumers need generated SDKs
    - Different teams own server and client
    - Need formal OpenAPI documentation for third parties
    
    ### Guard vs Inline Validation
    
    **Use `.guard()`:**
    
    - Multiple routes share the same validation (e.g., auth headers)
    - Want to encapsulate a group of protected routes
    - Cleaner than repeating schemas on every route
    
    **Use inline validation:**
    
    - Route has unique schema not shared with others
    - Simple one-off validation
    - Schema is small (1-2 fields)
    
    </decision_framework>
    
    ---
    
    <anti_patterns>
    
    ## Anti-Patterns to Avoid
    
    ### Breaking the Chain
    
    ```typescript
    // ANTI-PATTERN: Separate calls break type inference
    const app = new Elysia();
    
    app.get("/users", () => "list");
    app.post("/users", ({ body }) => body);
    
    export type App = typeof app; // Types won't include route details!
    ```
    
    **Why it's wrong:** Eden Treaty client will have no type information for routes. Type inference only flows through chained calls.
    
    **What to do instead:** Chain all route definitions: `new Elysia().get(...).post(...)`.
    
    ---
    
    ### Using Deprecated error() Function
    
    ```typescript
    // ANTI-PATTERN: error() is deprecated since 1.3
    import { Elysia } from "elysia";
    
    new Elysia().get("/", ({ error }) => {
      return error(404, "Not found"); // Deprecated!
    });
    ```
    
    **Why it's wrong:** `error()` was deprecated in 1.3 in favor of `status()`. Use `status()` for all new code.
    
    **What to do instead:** Use `status()`:
    
    ```typescript
    const HTTP_NOT_FOUND = 404;
    
    new Elysia().get("/", ({ status }) => {
      return status(HTTP_NOT_FOUND, "Not found");
    });
    ```
    
    ---
    
    ### Controller Classes with Context Parameters
    
    ```typescript
    // ANTI-PATTERN: Passing Context to class methods
    abstract class Controller {
      static root(context: Context) {
        // Hard to type, loses type integrity
      }
    }
    
    new Elysia().get("/", (ctx) => Controller.root(ctx));
    ```
    
    **Why it's wrong:** Creates "hard to type" situations, loses type safety. Context types are complex and change per route.
    
    **What to do instead:** Destructure what you need and call service functions with plain values:
    
    ```typescript
    new Elysia().get("/", ({ query }) => UserService.list(query));
    ```
    
    ---
    
    ### Overusing .decorate() for Request-Dependent Data
    
    ```typescript
    // ANTI-PATTERN: Decorating with per-request data
    new Elysia()
      .decorate("currentUser", null) // Will be same for ALL requests!
      .get("/profile", ({ currentUser }) => currentUser);
    ```
    
    **Why it's wrong:** `.decorate()` runs once at setup, not per request. All requests share the same value.
    
    **What to do instead:** Use `.derive()` for per-request computed values:
    
    ```typescript
    new Elysia()
      .derive(({ headers }) => ({
        currentUser: getUserFromToken(headers.authorization),
      }))
      .get("/profile", ({ currentUser }) => currentUser);
    ```
    
    ---
    
    ### Not Naming Plugins
    
    ```typescript
    // ANTI-PATTERN: Unnamed plugins get re-registered
    const auth = new Elysia() // No name!
      .derive(({ headers }) => ({ user: decodeJwt(headers.authorization) }));
    
    new Elysia()
      .use(auth) // Registered once
      .use(somePluginThatAlsoUsesAuth); // auth registered AGAIN
    ```
    
    **Why it's wrong:** Without `name`, Elysia cannot deduplicate. The derive handler runs twice per request.
    
    **What to do instead:**
    
    ```typescript
    const auth = new Elysia({ name: "auth" }).derive(({ headers }) => ({
      user: decodeJwt(headers.authorization),
    }));
    ```
    
    ---
    
    ### Forgetting Scope on Shared Hooks
    
    ```typescript
    // ANTI-PATTERN: Hook only applies locally
    const logger = new Elysia({ name: "logger" }).onBeforeHandle(({ request }) => {
      console.log(request.url);
    });
    
    new Elysia().use(logger).get("/", () => "hi"); // Logger does NOT run for this route!
    ```
    
    **Why it's wrong:** Lifecycle hooks default to `local` scope -- they only apply to routes inside the plugin, not to routes in the parent.
    
    **What to do instead:**
    
    ```typescript
    const logger = new Elysia({ name: "logger" }).onBeforeHandle(
      { as: "scoped" },
      ({ request }) => {
        console.log(request.url);
      },
    );
    ```
    
    ---
    
    ### Wrong URL in .handle() Tests
    
    ```typescript
    // ANTI-PATTERN: Partial URL path
    const response = await app.handle(new Request("/users")); // Throws!
    ```
    
    **Why it's wrong:** `.handle()` requires a fully qualified URL with protocol and host.
    
    **What to do instead:**
    
    ```typescript
    const response = await app.handle(new Request("http://localhost/users"));
    ```
    
    </anti_patterns>
    
    ---
    
    ## Production Checklist
    
    ### Before Deploying
    
    - [ ] All route definitions are method-chained (not separate statements)
    - [ ] `export type App = typeof app` present if using Eden Treaty
    - [ ] Using `status()` not `error()` for error responses
    - [ ] No magic numbers in validation schemas or status codes
    - [ ] Plugin instances have `name` property for deduplication
    - [ ] `.derive()` / `.resolve()` used for per-request data (not `.decorate()`)
    - [ ] Lifecycle hooks have correct scope (`local` / `scoped` / `global`)
    - [ ] `onError` handles `VALIDATION`, `NOT_FOUND`, `PARSE` codes at minimum
    - [ ] WebSocket handlers have TypeBox schemas for message validation
    - [ ] Tests use fully qualified URLs in `.handle()` calls
    - [ ] `await app.modules` called in tests if using lazy-loaded plugins
    
  • SKILL.md 10.5 KB
    ---
    name: api-framework-elysia
    description: Bun-native HTTP framework — routing, TypeBox validation, Eden Treaty, plugins, lifecycle hooks
    ---
    
    # API Development with Elysia
    
    > **Quick Guide:** Elysia is a Bun-native HTTP framework with end-to-end type safety. Use method chaining (not separate statements) so TypeScript infers the full route tree. Import `t` from `elysia` for TypeBox validation. Export the app type (`export type App = typeof app`) for Eden Treaty clients. Use `status()` (not the deprecated `error()` function) for error responses with type narrowing.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST use method chaining on the Elysia instance -- separate `.get()` calls break type inference for Eden Treaty)**
    
    **(You MUST use `status()` for error responses -- `error()` is deprecated since 1.3, prefer `status()`)**
    
    **(You MUST export the app type (`export type App = typeof app`) for Eden Treaty client generation)**
    
    </critical_requirements>
    
    ---
    
    **Auto-detection:** Elysia, elysia, ElysiaJS, Eden Treaty, @elysiajs/eden, @elysiajs/openapi, t.Object, t.String, t.Number, t.File, TypeBox, .derive(), .decorate(), .guard(), .macro(), .ws(), onBeforeHandle, onAfterHandle, onRequest, treaty, bun:test
    
    **When to use:**
    
    - Building APIs on Bun runtime with end-to-end type safety
    - Need RPC-style client with zero code generation (Eden Treaty)
    - TypeBox validation with AOT compilation (~18x faster than Zod on Bun)
    - Plugin-based architecture with automatic type propagation
    - WebSocket support with schema validation
    
    **When NOT to use:**
    
    - Deploying to Node.js-only environments without Bun (use a Node-first framework)
    - Need OpenAPI-first design with `createRoute` patterns (other frameworks with Zod-OpenAPI integration are more mature for this)
    - Team already committed to Express/Fastify ecosystem
    
    **Key patterns covered:**
    
    - Route definitions with method chaining and TypeBox validation
    - Plugin architecture with `.use()`, `.derive()`, `.decorate()`, `.macro()`
    - Scoping rules (local, scoped, global) and `.guard()`
    - End-to-end type safety with Eden Treaty
    - Lifecycle hooks (onRequest, onBeforeHandle, onAfterHandle, onError)
    - Error handling with custom error classes and `status()`
    - WebSocket with schema validation
    - Testing with `bun:test` and `.handle()` or Eden Treaty
    
    **Detailed Resources:**
    
    - [examples/core.md](examples/core.md) - Route setup, method chaining, validation, plugins
    - [examples/eden-treaty.md](examples/eden-treaty.md) - End-to-end type-safe client
    - [examples/lifecycle-errors.md](examples/lifecycle-errors.md) - Lifecycle hooks, error handling, custom errors
    - [examples/websocket-testing.md](examples/websocket-testing.md) - WebSocket patterns and unit testing
    - [reference.md](reference.md) - Decision frameworks, anti-patterns, production checklist
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    **Method chaining IS the type system.** Elysia infers the entire route tree through chained calls. Breaking the chain (separate `app.get()` statements) loses type information for Eden Treaty clients. This is the single most important architectural constraint.
    
    **TypeBox over Zod for Bun.** While Elysia 1.4+ supports Standard Schema (Zod, Valibot, etc.), TypeBox (`t` from `elysia`) uses AOT compilation inside Bun for ~18x faster validation. Use TypeBox as default; use Zod only when sharing schemas with a non-Bun codebase.
    
    **Plugins are Elysia instances.** Every `new Elysia()` is a plugin. There is no separate plugin API -- you compose by chaining `.use()`. The `name` property deduplicates plugins across the tree.
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: Route Setup with Method Chaining
    
    Chain route definitions on the Elysia instance. Each `.get()`, `.post()`, etc. returns the instance with updated type information.
    
    ```typescript
    import { Elysia, t } from "elysia";
    
    const app = new Elysia()
      .get("/", () => "hello")
      .get("/user/:id", ({ params: { id } }) => id, {
        params: t.Object({
          id: t.Number(),
        }),
      })
      .post("/user", ({ body }) => body, {
        body: t.Object({
          name: t.String(),
          email: t.String({ format: "email" }),
        }),
      })
      .listen(3000);
    
    export type App = typeof app;
    ```
    
    **Why good:** method chaining preserves type inference across the entire route tree, `export type App` enables Eden Treaty, TypeBox validates at runtime with AOT compilation
    
    See [examples/core.md](examples/core.md) for complete route setup with modular plugins.
    
    ---
    
    ### Pattern 2: Plugin Architecture
    
    Every Elysia instance is a plugin. Use `.use()` to compose, `name` to deduplicate.
    
    ```typescript
    import { Elysia } from "elysia";
    
    const userPlugin = new Elysia({ name: "user", prefix: "/user" })
      .get("/", () => "list users")
      .get("/:id", ({ params: { id } }) => `user ${id}`);
    
    const app = new Elysia().use(userPlugin).listen(3000);
    ```
    
    **Why good:** `name` prevents duplicate registration when a plugin is `.use()`-d multiple times, `prefix` scopes routes cleanly
    
    See [examples/core.md](examples/core.md) for `.derive()`, `.decorate()`, and `.macro()` patterns.
    
    ---
    
    ### Pattern 3: Scoping with Guard
    
    Apply validation schemas and lifecycle hooks to groups of routes.
    
    ```typescript
    import { Elysia, t } from "elysia";
    
    const app = new Elysia()
      .guard(
        {
          headers: t.Object({
            authorization: t.String(),
          }),
        },
        (app) =>
          app
            .get("/protected", ({ headers }) => headers.authorization)
            .post("/admin", ({ body }) => body, {
              body: t.Object({ action: t.String() }),
            }),
      )
      .get("/public", () => "no auth needed");
    ```
    
    **Why good:** guard encapsulates validation for route groups without repeating schema definitions, public routes outside the guard are unaffected
    
    ---
    
    ### Pattern 4: Error Handling with status()
    
    Use `status()` for typed error responses. Register custom error classes with `.error()`.
    
    ```typescript
    import { Elysia } from "elysia";
    
    const HTTP_UNAUTHORIZED = 401;
    const HTTP_NOT_FOUND = 404;
    
    class NotFoundError extends Error {
      status = HTTP_NOT_FOUND;
      constructor(public resource: string) {
        super(`${resource} not found`);
      }
    }
    
    const app = new Elysia()
      .error({ NotFoundError })
      .onError(({ code, error, status }) => {
        if (code === "NotFoundError") {
          return status(HTTP_NOT_FOUND, { error: error.message });
        }
        if (code === "VALIDATION") {
          return status(HTTP_UNAUTHORIZED, { error: "Validation failed" });
        }
      })
      .get("/user/:id", ({ params: { id }, status }) => {
        const user = findUser(id);
        if (!user) throw new NotFoundError("User");
        return user;
      });
    ```
    
    **Why good:** `status()` provides type narrowing for error responses, custom error classes with `.error()` enable `code`-based type narrowing in `onError`, named constants avoid magic status numbers
    
    See [examples/lifecycle-errors.md](examples/lifecycle-errors.md) for all lifecycle hooks and error patterns.
    
    ---
    
    ### Pattern 5: Eden Treaty Client
    
    Export the app type and use `treaty()` for a fully type-safe client with no code generation.
    
    ```typescript
    // server.ts
    import { Elysia, t } from "elysia";
    
    const app = new Elysia()
      .get("/user/:id", ({ params: { id } }) => ({ id, name: "Alice" }), {
        params: t.Object({ id: t.Number() }),
      })
      .post("/user", ({ body }) => body, {
        body: t.Object({ name: t.String() }),
      });
    
    export type App = typeof app;
    ```
    
    ```typescript
    // client.ts
    import { treaty } from "@elysiajs/eden";
    import type { App } from "./server";
    
    const api = treaty<App>("localhost:3000");
    
    const { data, error } = await api.user({ id: 1 }).get();
    // data is typed as { id: number; name: string } | null
    // error is typed based on error responses
    ```
    
    **Why good:** zero code generation, full autocomplete on paths and methods, error/data destructuring with type narrowing
    
    See [examples/eden-treaty.md](examples/eden-treaty.md) for response handling, file uploads, and WebSocket via Treaty.
    
    </patterns>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority:**
    
    - Separate `app.get()` / `app.post()` calls instead of chaining -- breaks Eden Treaty type inference entirely
    - Using deprecated `error()` function instead of `status()` -- deprecated since Elysia 1.3
    - Not exporting `type App = typeof app` -- Eden Treaty client has no type information
    - Using `as('plugin')` -- removed in 1.3+, use `as('scoped')` instead
    
    **Medium Priority:**
    
    - Importing patterns from other HTTP frameworks -- Elysia has its own routing API and conventions
    - Using `t.Object()` without named constants for limits/lengths -- magic numbers in validation schemas
    - Not providing `name` on plugin instances -- causes duplicate registration in complex app trees
    - `.derive()` or lifecycle hooks without `{ as: 'scoped' }` or `{ as: 'global' }` when parent routes need them -- hooks are local-scoped by default
    
    **Gotchas & Edge Cases:**
    
    - `params` are strings by default -- use `t.Number()` in the schema to coerce path params to numbers
    - `.handle()` in tests requires a fully qualified URL (`http://localhost/path`), NOT a path fragment (`/path`)
    - `.guard()` group standalone mode is default in 1.4+ -- guard schemas merge with route schemas instead of overwriting
    - TypeBox `t.File()` auto-detects `multipart/form-data` content type -- no need to set headers manually
    - `t.Files()` (plural) for multiple file uploads, `t.File()` for single
    - Eden Treaty dynamic path params use function syntax: `api.user({ id: 1 }).get()` not `api.user[1].get()`
    - When Eden Treaty response has status >= 300, `data` is always `null` and `error` has the value
    - Lifecycle hooks only apply to routes registered AFTER the hook -- order of `.on*()` and route definitions matters
    - `onError` receives a `code` string, not a status number -- switch on `code` for type narrowing
    - Cookies parse as JSON automatically if the value looks like JSON (1.3+ behavior)
    - `await app.modules` is required in tests when using lazy-loaded plugins (`import('./plugin')`)
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md**
    
    **(You MUST use method chaining on the Elysia instance -- separate `.get()` calls break type inference for Eden Treaty)**
    
    **(You MUST use `status()` for error responses -- `error()` is deprecated since 1.3, prefer `status()`)**
    
    **(You MUST export the app type (`export type App = typeof app`) for Eden Treaty client generation)**
    
    **Failure to follow these rules will break end-to-end type safety and Eden Treaty client generation.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related