← All posts
·13 min read

Claude Code with Elysia: Rules for End-to-End Types

Claude CodeElysiaBunTypeScript
Claude Code with Elysia: rules for end-to-end types

Why Elysia without CLAUDE.md ships types that drift from the runtime

Elysia is a fast, type-safe web framework built for Bun, where a single schema definition both validates a request at runtime and infers its TypeScript type, and where the Eden client gives the frontend end-to-end type safety against the server with no code generation. The appeal is that you write the schema once and get validation, types, and a typed client for free. The risk is that this magic depends on two things an assistant easily breaks: the method chain that Elysia's type inference flows through, and the discipline of defining a schema for every route. Break either and you get a server that compiles but validates nothing and types that lie about the runtime.

A Claude Code session that builds an Elysia app without project rules produces code that undermines the framework's guarantees. It splits the .get().post() chain into separate statements assigned to variables, which severs the type inference that depends on the chain being continuous. It writes a route handler that reads body.email with no schema, so the type is unknown and the runtime accepts anything. It casts a context value to a concrete type to silence an error, which detaches the type from the actual validated shape. It builds a frontend fetch by hand instead of using Eden, throwing away the end-to-end types entirely. Each of these is the difference between a server whose types match what it validates and one whose types are decorative, and none of it fails until a malformed request slips through or the client and server drift apart.

This guide covers the CLAUDE.md configuration that locks Claude Code into Elysia patterns that keep the types honest: the schema-as-validation contract where every route defines its shape, the method-chaining rule that inference depends on, the Eden client for end-to-end types, the lifecycle-hook conventions, and permission hooks that keep an automated session from breaking the build. For the runtime Elysia targets, Claude Code with Bun covers the Bun-specific patterns. For a Node-first typed alternative, Claude Code with Fastify covers the schema-first approach on a different runtime.

The Elysia CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Elysia project it needs to declare: the version and runtime, the schema contract, the chaining requirement, the Eden conventions, and the hard rules that block the inference-breaking patterns Claude introduces most often.

# Elysia rules

## Framework
- Elysia v1 on Bun, schema via the built-in `t` (TypeBox)
- Eden Treaty for the typed client, NO manual fetch to our own API
- Types are INFERRED from schemas, not annotated by hand

## Schema = validation (MANDATORY)
- EVERY route with input defines a schema: body, query, params, headers
- The schema validates at runtime AND infers the handler's types
- A route without a schema for its input is NOT done
- Use t.Object, t.String, t.Number, etc. from the `t` export

## Method chaining (MANDATORY, inference depends on it)
- Routes are chained: app.get(...).post(...).put(...)
- NEVER split the chain into separate statements (breaks type flow)
- Plugins added with .use() in the chain, NEVER reassigned to a variable mid-chain
- The returned app type carries every route's types; keep the chain continuous

## Context and handlers (MANDATORY)
- Destructure context: ({ body, query, params, set, error })
- Validated input is already typed; NEVER cast body/query/params
- Set status via `set.status`, return the body value directly
- Errors via the `error(status, message)` helper, NOT thrown raw

## Eden client
- Frontend calls the API through Eden Treaty, types flow from the server
- NEVER hand-write a fetch wrapper for our own endpoints
- The Eden client imports the server's App type, no codegen

## Hard rules
- NEVER define a route with input but no schema
- NEVER split the route chain into separate assignments
- NEVER cast a validated context value (body/query/params)
- NEVER hand-roll fetch to our own API when Eden is available
- NEVER reassign the app mid-chain in a way that drops route types
- ALWAYS validate input with a `t` schema on every route
- ALWAYS keep the method chain continuous for inference
- ALWAYS return the body directly and set status via `set`

Three rules here prevent the majority of the breakage Claude generates without them.

The schema-as-validation rule is the foundation. In Elysia, a route's schema is simultaneously the runtime validator and the source of the handler's types. A route defined without a schema for its body accepts any payload at runtime and types the body as unknown, so the developer casts it, which makes the type a fiction. Claude skips the schema because plenty of frameworks treat validation as optional. The rule makes a schema mandatory for every route with input, which is what ties the type to the runtime check.

The method-chaining rule protects Elysia's most surprising requirement. Elysia's end-to-end type inference works by accumulating every route's types onto the app type as you chain .get().post() calls. The moment you break the chain, assigning the app to a variable and calling .post() on it in a separate statement, the inference can lose the accumulated types, and Eden on the client side sees an incomplete API. Claude breaks the chain naturally because most frameworks do not care. The rule requires keeping the chain continuous, which is non-obvious and exactly the kind of thing a CLAUDE.md exists to encode.

The no-cast rule keeps the inference honest. Because the schema already types the validated input, casting body to a concrete type is both unnecessary and dangerous: it detaches the static type from what the schema actually validated, so a schema change no longer updates the handler's types. Claude casts out of habit when it is unsure. The rule forbids casting validated context values and relies on the schema's inference.

Install and project structure

Elysia runs on Bun, so install with Bun and add the Eden client for the frontend.

# Core framework (Bun runtime)
bun add elysia

# Eden client for end-to-end types
bun add @elysiajs/eden

# Common plugins
bun add @elysiajs/cors @elysiajs/swagger

Keep the app type exportable so Eden can import it, and group routes so Claude keeps each chain continuous.

src/
  index.ts           , app entry, exports the App type for Eden
  routes/
    users.ts         , a continuous Elysia chain, exported as a plugin
    posts.ts         , a continuous Elysia chain
  lib/
    schemas.ts       , shared t.Object schemas
client/
  api.ts             , Eden Treaty client importing the App type

The structure supports the two rules that matter. Each route file is one continuous Elysia chain exported as a plugin, so the chaining rule is satisfied per file and the parent app composes them with .use(). The schemas.ts holds shared t schemas so the same validation defines types in one place. The client/api.ts imports the exported App type and builds the Eden client, which is what carries the server's types to the frontend with no codegen. This layout is what the CLAUDE.md conventions assume.

Schema as the single source of truth

The defining pattern of Elysia is the schema that validates and types at once. A route declares its body, params, or query with the t builder, and the handler receives the validated, typed value.

// src/routes/users.ts
import { Elysia, t } from "elysia";

export const users = new Elysia({ prefix: "/users" })
  .post(
    "/",
    ({ body }) => {
      // body is typed { name: string; email: string } from the schema
      return createUser(body);
    },
    {
      body: t.Object({
        name: t.String({ minLength: 1 }),
        email: t.String({ format: "email" }),
      }),
    }
  )
  .get(
    "/:id",
    ({ params }) => getUser(params.id),
    { params: t.Object({ id: t.String() }) }
  );

The body schema does double duty. At runtime, Elysia rejects any request whose body does not match, so name must be a non-empty string and email must be a valid email before the handler runs. At compile time, the handler's body is typed as exactly { name: string; email: string } with no annotation, because the type is inferred from the same schema. The params schema does the same for the route parameter. There is no path where the handler sees an unvalidated value, which is the property the schema-as-validation rule protects. Because the type comes from the schema, a change to the schema updates the handler's type automatically, and casting would break that link. For deeper validation patterns, Claude Code with Zod covers the equivalent concepts in a different validator.

Keeping the chain continuous

Elysia's type inference accumulates onto the app as routes chain, so the chain must stay continuous. The route file above is one chain; the parent app composes route plugins with .use(), also in a chain.

// src/index.ts
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { users } from "./routes/users";
import { posts } from "./routes/posts";

const app = new Elysia()
  .use(cors())
  .use(users)
  .use(posts)
  .get("/health", () => ({ status: "ok" }))
  .listen(3000);

// Export the app TYPE for Eden; this carries every route's types
export type App = typeof app;

The chain is unbroken from new Elysia() through every .use() and .get() to .listen(). Each .use(plugin) merges the plugin's accumulated route types into the app type, and each inline route adds its own, so typeof app at the end describes the complete API surface. This is the App type Eden imports. If Claude split this into const app = new Elysia(); app.use(users); app.use(posts);, the reassignment could drop the accumulated types and the exported App would be incomplete, breaking the client's type safety silently. The chaining rule is what keeps the type complete, and it is the least intuitive rule in the whole integration.

End-to-end types with Eden

Eden Treaty consumes the exported App type and gives the frontend a fully typed client with no code generation. Calling an endpoint is type-checked against the server's actual routes and schemas.

// client/api.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "../src/index";

export const api = treaty<App>("http://localhost:3000");

// Usage, fully typed against the server:
async function loadUser(id: string) {
  const { data, error } = await api.users({ id }).get();
  if (error) throw error;
  return data; // typed from the server's getUser return
}

async function addUser(name: string, email: string) {
  // The body is type-checked against the server's t.Object schema
  const { data, error } = await api.users.post({ name, email });
  if (error) throw error;
  return data;
}

Eden imports only the App type, not any runtime code, so there is no build step and no generated file to drift. The api.users({ id }).get() call maps to the server's GET /users/:id and returns the handler's typed result. The api.users.post({ name, email }) call is type-checked against the server's body schema, so passing a payload that the schema would reject is a compile error before any request is sent. This is the end-to-end safety that the Eden rule preserves by forbidding hand-rolled fetch, which would throw all of it away. The whole guarantee rests on the App type being complete, which is why the chaining rule and the Eden rule reinforce each other.

Lifecycle hooks and error handling

Elysia provides lifecycle hooks for cross-cutting concerns and an error helper for typed error responses. The pattern keeps hooks in the chain and errors returned through the helper rather than thrown raw.

import { Elysia, t } from "elysia";

export const protectedRoutes = new Elysia()
  .onBeforeHandle(({ headers, error }) => {
    const token = headers.authorization;
    if (!token) return error(401, "missing authorization");
  })
  .get("/me", ({ headers }) => getCurrentUser(headers.authorization!));

The onBeforeHandle hook runs before every handler in this chain and checks for an authorization header, returning error(401, ...) when it is missing. The error helper produces a typed error response that Eden surfaces on the client as the error field, so the client handles it without a thrown exception crossing the type boundary. Returning the error from the hook short-circuits the request before the handler runs, which is the Elysia idiom for guards. Because the hook is part of the chain, it composes with the route types correctly. This is the lifecycle convention the CLAUDE.md encodes, and it keeps error handling typed end-to-end rather than relying on raw throws that Eden cannot describe.

Permission hooks for Elysia operations

An Elysia project runs on Bun, so its risky automated operations involve Bun, the build, and dependencies. Permission hooks in .claude/settings.local.json keep the destructive ones out of an automated session.

{
  "permissions": {
    "allow": [
      "Bash(bun run dev*)",
      "Bash(bun run build*)",
      "Bash(bun test*)",
      "Bash(bunx tsc --noEmit*)"
    ],
    "deny": [
      "Bash(rm -rf node_modules*)",
      "Bash(bun install * --force*)",
      "Bash(bun pm cache rm*)",
      "Bash(*.env*)"
    ]
  }
}

The allow list permits the operations an Elysia session needs: the dev server, a build, tests, and a type-check, all through Bun. The deny list blocks wiping node_modules, forced installs that could pull a mismatched Elysia version, clearing the Bun package cache, and any shell command touching a .env file. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can build and test the server freely without breaking the dependency tree or exposing secrets. For the broader Bun toolchain, Claude Code with Bun covers the runtime-specific operations.

Common Claude Code mistakes with Elysia

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. Route with input but no schema

Claude generates: a .post("/", ({ body }) => ...) with no body schema.

Correct pattern: add { body: t.Object({ ... }) } so it validates and types.

2. Broken method chain

Claude generates: const app = new Elysia(); app.get(...); app.post(...);.

Correct pattern: keep the chain continuous so route types accumulate on the app.

3. Casting a validated context value

Claude generates: const b = body as CreateUser.

Correct pattern: rely on the schema's inferred type; never cast body.

4. Hand-rolled fetch to own API

Claude generates: a fetch("/users") wrapper on the client.

Correct pattern: use the Eden treaty<App> client for end-to-end types.

5. Raw throw for errors

Claude generates: throw new Error("unauthorized") in a handler.

Correct pattern: return error(401, "unauthorized") so Eden surfaces it typed.

6. App type not exported for Eden

Claude generates: an app with no exported type, so Eden cannot import it.

Correct pattern: export type App = typeof app after the chain completes.

Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is Elysia code Claude generates correctly the first time.

Testing the Elysia integration

The tests that matter for Elysia prove the schema validates and the types are honest: a malformed body is rejected at runtime, and a valid one passes. Because Elysia apps expose a handle method, you can test them without a network listener.

import { describe, it, expect } from "vitest";
import { users } from "../src/routes/users";

describe("users route", () => {
  it("rejects a body that fails the schema", async () => {
    const res = await users.handle(
      new Request("http://localhost/users", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ name: "", email: "not-an-email" }),
      })
    );
    expect(res.status).toBe(422);
  });

  it("accepts a valid body", async () => {
    const res = await users.handle(
      new Request("http://localhost/users", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ name: "Ada", email: "ada@test.dev" }),
      })
    );
    expect(res.status).toBe(200);
  });
});

The first test sends a body that violates the schema, an empty name and an invalid email, and asserts Elysia rejects it with a 422 before the handler runs, which proves the schema is validating at runtime and not just typing. The second sends a valid body and confirms it passes. Together they prove the schema-as-validation contract holds, which is the property the whole integration depends on. If a route lost its schema, the first test would fail because the bad body would be accepted. For the broader testing setup, Claude Code with Vitest covers the configuration patterns.

Building Elysia where the types match the runtime

The Elysia CLAUDE.md in this guide produces an app where every route defines a schema that both validates at runtime and infers the handler's types, the method chain stays continuous so route types accumulate onto the exported App type, the frontend uses Eden for end-to-end safety, validated context values are never cast, and errors return through the typed error helper. The result is a server whose types describe exactly what it validates, with a client that cannot call it wrong, rather than a codebase where the types are decorative and drift from the runtime.

The principle is the same as any type-driven integration with Claude Code. Elysia without a CLAUDE.md produces code that compiles while quietly breaking the two mechanisms that make the framework worth choosing: the schema-to-type link and the chain-based inference. The CLAUDE.md is what keeps the schema mandatory and the chain intact so the end-to-end types stay true.

For the Bun runtime Elysia targets, Claude Code with Bun covers the runtime-specific patterns. For a Node-first typed alternative with schema validation, Claude Code with Fastify covers the schema-first approach. For another minimal framework with a similar context model, Claude Code with Hono covers the edge-ready patterns.

Get Claudify. Ship Elysia code where the schema validates, the chain holds, and the types reach the client intact.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame