CodeWorlds
Back to collections
Guide14 min readCodeWorlds Team

Elysia, a Bun framework with end-to-end types

Elysia carries a route type from the server to the client through Eden over plain HTTP. Version 1.4.29, MIT licence, Node support and the cost of Bun coupling.

Elysia, a Bun framework with end-to-end types

Elysia is an HTTP framework written in TypeScript and built around the Bun runtime. The promise is narrow and specific: a route type defined on the server reaches the client with no code generation and no hand-maintained interfaces. The current stable version is 1.4.29, the licence is MIT, and the repository is elysiajs/elysia.

What Elysia actually does

Elysia is an HTTP router with built-in input validation, a request lifecycle and documentation generation. The interface is chained, and that is not a matter of taste. Every .get(), .post() or .use() call returns a new instance type enriched with information about the route just added. Once the application is assembled, typeof app holds a full map of paths together with the shape of the request body, query parameters and response. That is the foundation everything else stands on: no chain means no type, and no type means no Eden client.

Underneath sits a compiler that generates handling code for each route at startup instead of interpreting configuration on every request. The aot option governs it, enabled by default and supported by static analysis of handler code named Sucrose. If a handler never touches body, the generated code skips body parsing entirely. The same layer is responsible for nativeStaticResponse, which hands constant responses straight to the Bun server.

Validation rests on TypeBox, exposed through the t export. A schema described by t.Object serves three purposes at once: it checks data at runtime, derives the TypeScript type for the handler, and becomes an entry in the OpenAPI document. Elysia adds its own helper types on top of TypeBox, among them t.Numeric for numeric values arriving as text, t.File and t.Files for uploads, plus t.Form, t.UnionEnum, t.Nullable and t.ObjectString.

What Elysia does not do matters just as much. There is no database access layer, so Drizzle ORM or Prisma remain a separate choice. There is no server-side view rendering in the sense of Next.js. There is no built-in authentication beyond a plugin that reads the Bearer header, so session logic is yours to write or to import.

Version, licence and two npm scopes

The version tagged latest is 1.4.29, published on 16 June 2026. The package has 749 releases behind it, which against a repository created in December 2022 makes for a very dense rhythm. Work on version two runs in parallel: the next tag points at 2.0.0-beta.6 from 19 August 2026, and the experimental tag at 2.0.0-exp.64. That means the stable line has had no release since June while the main branch is very much alive. If you are planning a deployment measured in years, budget for a migration to version two.

The licence agrees across all three places where such things usually diverge. The LICENSE file on the main branch carries the MIT text with the note "Copyright 2022 saltyAom". The license field in the npm registry reads MIT. The published elysia-1.4.29.tgz archive contains a package/LICENSE file byte for byte identical to the one in the repository. GitHub's licence detector also reports MIT. That is a rare case of complete agreement, and a dependency audit finds nothing to clarify here.

The naming scope causes far more confusion. Plugins exist on npm in two variants: the older @elysiajs/ and the newer @elysia/, published by the same maintainer. The Node adapter is @elysiajs/node at version 1.4.5 and @elysia/node at version 1.4.6. The client is @elysiajs/eden at version 1.4.9 with 110 releases behind it, and @elysia/eden at version 1.4.10 with eight. The documentation on the project site instructs installation from the new scope, while the README inside the published Eden package shows the old scope along with the old interface, complete with an edenTreaty function and a schema wrapper that no longer exists. On first contact with the project it is easy to install a package from one scope and copy an example written for the other.

There is no paid variant. Funding runs through GitHub Sponsors, and the project is in practice the work of a single author publishing under the aomkirby123 account. That carries a known risk: no enforceable support contract, and development that depends on one person's availability.

Routes, validation and type inference

Installing and running takes a handful of commands.

Code
Bash
# a new project from the scaffold
bun create elysia app

# or adding to an existing project
bun add elysia

# client and documentation
bun add @elysia/eden @elysia/openapi

# running with reload
bun --watch src/index.ts

# compiling to a single executable
bun build --compile --minify --sourcemap src/index.ts --outfile server

The server itself looks like this. The example below uses only names present in version 1.4.29.

Code
TypeScript
import { Elysia, t, status } from 'elysia'
import { openapi } from '@elysia/openapi'

const app = new Elysia({
  prefix: '/api',
  aot: true,
  normalize: 'exactMirror',
  strictPath: false,
  nativeStaticResponse: true
})
  .use(openapi())
  .model({
    user: t.Object({
      id: t.Numeric(),
      email: t.String({ format: 'email' }),
      displayName: t.String({ minLength: 2, maxLength: 40 })
    })
  })
  .state('requestCount', 0)
  .derive(({ headers }) => ({
    traceId: headers['x-trace-id'] ?? crypto.randomUUID()
  }))
  .get(
    '/users',
    ({ query, store }) => {
      store.requestCount += 1
      return { items: [], page: query.page }
    },
    {
      query: t.Object({
        page: t.Numeric({ default: 1 }),
        search: t.Optional(t.String())
      }),
      detail: { summary: 'User list', tags: ['users'] }
    }
  )
  .post('/users', ({ body }) => body, {
    body: 'user',
    response: {
      200: 'user',
      409: t.Object({ message: t.String() })
    }
  })
  .get('/users/:id', ({ params: { id } }) => {
    if (id > 1000) return status(404, { message: 'Not found' })
    return { id, email: 'a@b.com', displayName: 'Ada' }
  }, {
    params: t.Object({ id: t.Numeric() })
  })
  .onError(({ code, error, set }) => {
    if (code === 'VALIDATION') {
      set.status = 422
      return { message: error.message }
    }
  })
  .listen(3000)

export type App = typeof app

A few things deserve comment. The body: 'user' field refers to a schema registered through .model(), so the same shape feeds validation, types and the OpenAPI document from one place. The status function replaced the earlier error and returns a response with a specific code while preserving that code in the route type. The normalize option set to exactMirror strips response fields absent from the schema, which protects you from leaking a password column when returning database rows. The last line, export type App = typeof app, is the entire contract export mechanism.

Repeated logic gets wrapped in a macro, which attaches lifecycle events to a route along with their types.

Code
TypeScript
import { Elysia, status } from 'elysia'

const auth = new Elysia({ name: 'auth' }).macro({
  requireUser: {
    resolve({ headers }) {
      const token = headers.authorization?.slice(7)
      if (!token) return status(401, { message: 'Missing token' })
      return { user: { id: 1, role: 'admin' as const } }
    }
  }
})

const routes = new Elysia()
  .use(auth)
  .get('/me', ({ user }) => user, { requireUser: true })

The user field inside the handler is known to the compiler because it comes from resolve in the macro. There is no cast and no declaration widening the context type.

Eden, the same types on the client

Eden is a client that takes the server type as a generic parameter and builds an object mirroring the path tree from it. The path /users/:id becomes the call api.users({ id: 5 }).get(), and the HTTP method is the last call in the chain.

Code
TypeScript
import { treaty } from '@elysia/eden'
import type { App } from '../server/index'

const api = treaty<App>('localhost:3000', {
  headers: { 'x-trace-id': crypto.randomUUID() }
})

const { data, error } = await api.api.users.get({
  query: { page: 2, search: 'ada' }
})

if (error) {
  switch (error.status) {
    case 422:
      console.error(error.value.message)
      break
    default:
      throw error
  }
}

console.log(data?.items)

const created = await api.api.users.post({
  id: 7,
  email: 'ada@example.com',
  displayName: 'Ada'
})

The effect matches tRPC: changing the response shape on the server breaks the client build before anyone runs the application. The difference lies in the transport layer. tRPC wraps calls in its own format and requires its own client on the other side. Elysia exposes ordinary HTTP routes that can be called from curl, from a mobile app or from a service written in another language, with Eden as an add-on for TypeScript clients only. On top of that the OpenAPI plugin produces a document describing those same routes, so a consumer outside the ecosystem also gets a contract.

The limitation is real: splitting server and client across two repositories breaks the arrangement, because typeof app must be visible to the compiler on the client side. It works in a monorepo or when you publish a package carrying the types. The second limitation is compile cost. The instance type grows with every route, and at a few hundred routes the TypeScript language server slows down noticeably. The remedy is splitting the application into smaller instances joined through .use().

Bun, Node and other runtimes

Elysia was created for Bun and that is where the path is shortest: the elysia/adapter/bun adapter is the default, listen maps onto Bun.serve, and websockets work without an extra library. The package declares no engines field, however, and the dist/adapter directory holds three variants: bun, web-standard and cloudflare-worker.

Running on Node is a genuine option and comes down to one constructor setting.

Code
TypeScript
import { Elysia } from 'elysia'
import { node } from '@elysia/node'

const app = new Elysia({ adapter: node() })
  .get('/', () => 'Hello Node')
  .listen(3000)

// the Deno variant: no listen, through Web Standard
// Deno.serve(app.fetch)

// the unit test variant, with no port opened
const response = await app.handle(
  new Request('http://localhost/', { method: 'GET' })
)
console.log(response.status, await response.text())

The Node adapter rests on the srvx and crossws packages, which map the Web Standard interface onto a Node HTTP server and onto websockets. Deno needs no adapter at all, since passing app.fetch to Deno.serve is enough. Cloudflare Workers have their own adapter shipped inside the package.

The adapter layer does not remove the main reservation, though. Documentation, examples, plugins and community answers all assume Bun. Plugins reach for Bun APIs, compiling to a single executable is a Bun feature, and the multi-core mode described in the deployment docs relies on SO_REUSEPORT, which works on Linux only and under Bun only. Outside Bun you get a working framework minus part of what made you choose it. If your deployment platform has no Bun image, or the team refuses to add a second runtime next to Node, that settles the question rather than being a detail.

The project's own materials quote a figure of eighteen times faster than Express, based on the TechEmpower suite. That README fragment is commented out inside the published package, and any such number comes from a synthetic test where transport dominates request time rather than application code. In a service that queries a database, the gap between frameworks usually disappears into the noise.

Elysia against the alternatives

TraitElysiaHonotRPCFastify
Default runtimeBunanything Web Standard complianta layer above a frameworkNode
Client-side typesEden treatythe hc clientthe library corenone
Protocolplain HTTP and OpenAPIplain HTTPcustom format over HTTPplain HTTP
ValidationTypeBox via the t exportany, through middlewareany, for instance ZodJSON Schema via Ajv
Current version1.4.294.13.311.18.05.12.1
LicenceMITMITMITMIT

The choice settles on three questions. If you already stand on Bun and are writing a programming interface consumed by your own TypeScript front end, Elysia offers the shortest path from a route to a browser call. If you must deploy to several platforms or to the edge, Hono does the same thing for types through its hc client while running anywhere Web Standard runs. If client and server live in one repository and nobody outside the team will call those routes, tRPC remains the most polished option, though you pay for it with a custom protocol.

Common mistakes

The first is breaking the chain into separate statements. Writing app.get(...) on its own line without reassigning the result executes correctly at runtime but adds no route to the instance type. The effect is misleading: the server answers while Eden insists the path does not exist. The chain has to stay one expression, or the result must be assigned back.

The second is mixing the @elysia/ and @elysiajs/ scopes in one project. Both versions of a package can land in node_modules together, and then you get two distinct modules carrying the same type names and compile errors whose text points nowhere near the cause. Pick one scope and hold to it across the repository.

The third is copying examples from the README of the published Eden package. It carries the first-generation edenTreaty function and a schema wrapper on route definitions that the current version does not accept. Current examples live in the documentation on the project site, not in the package.

The fourth is omitting the response schema. Without a response field Elysia returns whatever the handler produces, including every column pulled from the database. A response schema combined with normalize set to exactMirror strips surplus fields and describes the contract in the OpenAPI document at the same time.

The fifth is treating query parameters as numbers. Everything arriving in a URL is text. A t.Number() schema rejects the value "2", while t.Numeric() converts it before the handler runs. The same trap applies to path parameters.

The sixth is assuming a plugin written for Bun will work once the Node adapter is attached. The adapter translates the server layer, not every Bun API call made inside a plugin. When running on Node, each plugin has to be checked individually.

The seventh is one giant instance holding several hundred routes. The TypeScript compiler must maintain a single type describing the whole thing, and editor completion time grows along with it. Splitting into domain modules joined through .use() solves the problem before it appears.

FAQ

Does Elysia run on Node?

Yes, through a separately installed adapter passed to the constructor as new Elysia({ adapter: node() }). The adapter rests on the srvx and crossws packages. The framework itself declares no engines field, so a package manager will not block installation on Node. Outside Bun you lose executable compilation and some plugins that reach for Bun APIs.

How does Eden differ from tRPC?

In how far it reaches into the protocol. tRPC defines its own call format and requires its own client. Elysia exposes ordinary HTTP routes, with Eden as an optional overlay for TypeScript clients. A route can be called from curl or from a service in another language, and the OpenAPI plugin generates a document for it.

Which naming scope should I install plugins from?

The project documentation instructs installation from the @elysia/ scope, and versions there are newer: the Node adapter reads 1.4.6 against 1.4.5 in the @elysiajs/ scope, and Eden 1.4.10 against 1.4.9. The older scope has far more releases behind it and is still maintained by the same person. Mixing both in one project ends in a type conflict.

Is Elysia production ready?

The 1.4 line is stable and widely used, but the last stable release dates from 16 June 2026, while work is heading toward version two, released as a beta since August 2026. The repository holds roughly 18.9 thousand stars, 568 forks and 373 open issues. When planning a deployment, account for a future migration.

Does Elysia cost anything?

No. The whole project is MIT licensed, with no paid variant and no restriction on commercial use. Funding runs through GitHub Sponsors, and the author is in practice one person, which translates into no support contract and development tied to their availability.

Documentation lives on the project site, the client is described in the Eden section, and the source sits in the GitHub repository.

Read next

We use cookies to enhance your experience on the site