ElysiaJS + Bun: Build a Type-Safe REST API with End-to-End Type Safety

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

ElysiaJS + Bun: Build a Type-Safe REST API with End-to-End Type Safety

If you've been building APIs with Express or Fastify, it's time to meet ElysiaJS — a Bun-native web framework that delivers end-to-end type safety, automatic OpenAPI documentation, and performance that rivals Go and Rust frameworks.

While Hono focuses on multi-runtime portability, ElysiaJS goes all-in on Bun's performance and TypeScript's type system. The result? An API framework where your route handlers, validation schemas, and even your frontend client share a single source of truth for types — with zero code generation.

Why ElysiaJS?

Here's what makes ElysiaJS stand out in the crowded TypeScript API space:

  • End-to-end type safety: Types flow from your route definition to validation to the client — no any, no casting
  • Bun-native performance: Built specifically for Bun, leveraging its fast HTTP server internals
  • Automatic OpenAPI/Swagger: Every validated route auto-generates OpenAPI 3.0 docs
  • Eden Treaty: A type-safe client that infers types directly from your server — no codegen needed
  • Plugin ecosystem: Bearer auth, CORS, JWT, GraphQL, and more as first-party plugins
  • Declarative validation: TypeBox-based schema validation that doubles as TypeScript types

Prerequisites

Before starting, ensure you have:

  • Bun 1.1+ installed (bun.sh)
  • Basic TypeScript knowledge
  • Familiarity with REST API concepts
  • A code editor (VS Code recommended)

This tutorial uses ElysiaJS 1.2+ and Bun 1.1+. If you're coming from Express or Hono, you'll find many familiar patterns with powerful additions.

What You'll Build

We'll build a complete Task Management API with:

  • Full CRUD operations for tasks
  • Input validation with type-safe schemas
  • Bearer token authentication
  • Route grouping and guards
  • Automatic Swagger documentation
  • A type-safe client using Eden Treaty

Step 1: Project Setup

Create a new ElysiaJS project using Bun's built-in scaffolding:

bun create elysia task-api
cd task-api

This creates a minimal project structure. Let's install the plugins we'll need:

bun add @elysiajs/swagger @elysiajs/bearer @elysiajs/cors

Your project structure should look like this:

task-api/
├── src/
│   └── index.ts
├── package.json
├── tsconfig.json
└── bun.lock

Let's organize it for a real-world API:

mkdir -p src/{routes,models,plugins,middleware}

Updated structure:

task-api/
├── src/
│   ├── routes/
│   │   └── tasks.ts
│   ├── models/
│   │   └── task.model.ts
│   ├── plugins/
│   │   └── auth.ts
│   ├── middleware/
│   └── index.ts
├── package.json
└── tsconfig.json

Step 2: Define Your Data Models

ElysiaJS uses TypeBox (t) for validation schemas. The beauty is that these schemas are simultaneously:

  1. Runtime validators (like Zod)
  2. TypeScript types (via inference)
  3. OpenAPI schema definitions

Create src/models/task.model.ts:

import { Elysia, t } from 'elysia'
 
// Define task schemas as an Elysia plugin for reuse
export const taskModel = new Elysia({ name: 'Model.Task' })
  .model({
    // Schema for creating a task
    'task.create': t.Object({
      title: t.String({ minLength: 1, maxLength: 200 }),
      description: t.Optional(t.String({ maxLength: 1000 })),
      priority: t.Optional(
        t.Union([
          t.Literal('low'),
          t.Literal('medium'),
          t.Literal('high')
        ], { default: 'medium' })
      ),
      dueDate: t.Optional(t.String({ format: 'date' }))
    }),
 
    // Schema for updating a task
    'task.update': t.Object({
      title: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
      description: t.Optional(t.String({ maxLength: 1000 })),
      priority: t.Optional(
        t.Union([
          t.Literal('low'),
          t.Literal('medium'),
          t.Literal('high')
        ])
      ),
      completed: t.Optional(t.Boolean()),
      dueDate: t.Optional(t.String({ format: 'date' }))
    }),
 
    // Schema for task response
    'task.response': t.Object({
      id: t.String(),
      title: t.String(),
      description: t.Nullable(t.String()),
      priority: t.Union([
        t.Literal('low'),
        t.Literal('medium'),
        t.Literal('high')
      ]),
      completed: t.Boolean(),
      dueDate: t.Nullable(t.String()),
      createdAt: t.String(),
      updatedAt: t.String()
    }),
 
    // Common params
    'task.params': t.Object({
      id: t.String()
    })
  })

By wrapping models in an Elysia plugin, you get auto-completion when referencing model names in route handlers. No string typos — the compiler catches them.

Step 3: Build the In-Memory Data Store

For this tutorial, we'll use a simple in-memory store. In production, you'd swap this with a database (SQLite via Bun, Drizzle ORM, Prisma, etc.).

Create src/models/store.ts:

export interface Task {
  id: string
  title: string
  description: string | null
  priority: 'low' | 'medium' | 'high'
  completed: boolean
  dueDate: string | null
  createdAt: string
  updatedAt: string
}
 
// Simple in-memory store
class TaskStore {
  private tasks: Map<string, Task> = new Map()
 
  create(data: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Task {
    const id = crypto.randomUUID()
    const now = new Date().toISOString()
    const task: Task = {
      id,
      ...data,
      createdAt: now,
      updatedAt: now
    }
    this.tasks.set(id, task)
    return task
  }
 
  getAll(): Task[] {
    return Array.from(this.tasks.values())
  }
 
  getById(id: string): Task | undefined {
    return this.tasks.get(id)
  }
 
  update(id: string, data: Partial<Omit<Task, 'id' | 'createdAt'>>): Task | undefined {
    const task = this.tasks.get(id)
    if (!task) return undefined
 
    const updated: Task = {
      ...task,
      ...data,
      updatedAt: new Date().toISOString()
    }
    this.tasks.set(id, updated)
    return updated
  }
 
  delete(id: string): boolean {
    return this.tasks.delete(id)
  }
 
  filter(predicate: (task: Task) => boolean): Task[] {
    return this.getAll().filter(predicate)
  }
}
 
export const taskStore = new TaskStore()

Step 4: Create the Task Routes

Now for the core of our API. Create src/routes/tasks.ts:

import { Elysia, t } from 'elysia'
import { taskModel } from '../models/task.model'
import { taskStore } from '../models/store'
 
export const taskRoutes = new Elysia({ prefix: '/tasks' })
  .use(taskModel)
 
  // GET /tasks - List all tasks with optional filtering
  .get('/', ({ query }) => {
    let tasks = taskStore.getAll()
 
    if (query.completed !== undefined) {
      tasks = tasks.filter(t => t.completed === (query.completed === 'true'))
    }
 
    if (query.priority) {
      tasks = tasks.filter(t => t.priority === query.priority)
    }
 
    return {
      data: tasks,
      total: tasks.length
    }
  }, {
    query: t.Object({
      completed: t.Optional(t.String()),
      priority: t.Optional(
        t.Union([
          t.Literal('low'),
          t.Literal('medium'),
          t.Literal('high')
        ])
      )
    }),
    detail: {
      tags: ['Tasks'],
      summary: 'List all tasks'
    }
  })
 
  // GET /tasks/:id - Get a single task
  .get('/:id', ({ params, status }) => {
    const task = taskStore.getById(params.id)
 
    if (!task) {
      return status(404, {
        error: 'Task not found',
        id: params.id
      })
    }
 
    return task
  }, {
    params: 'task.params',
    detail: {
      tags: ['Tasks'],
      summary: 'Get task by ID'
    }
  })
 
  // POST /tasks - Create a new task
  .post('/', ({ body }) => {
    const task = taskStore.create({
      title: body.title,
      description: body.description ?? null,
      priority: body.priority ?? 'medium',
      completed: false,
      dueDate: body.dueDate ?? null
    })
 
    return task
  }, {
    body: 'task.create',
    response: 'task.response',
    detail: {
      tags: ['Tasks'],
      summary: 'Create a new task'
    }
  })
 
  // PATCH /tasks/:id - Update a task
  .patch('/:id', ({ params, body, status }) => {
    const task = taskStore.update(params.id, {
      ...(body.title !== undefined && { title: body.title }),
      ...(body.description !== undefined && { description: body.description }),
      ...(body.priority !== undefined && { priority: body.priority }),
      ...(body.completed !== undefined && { completed: body.completed }),
      ...(body.dueDate !== undefined && { dueDate: body.dueDate })
    })
 
    if (!task) {
      return status(404, {
        error: 'Task not found',
        id: params.id
      })
    }
 
    return task
  }, {
    params: 'task.params',
    body: 'task.update',
    detail: {
      tags: ['Tasks'],
      summary: 'Update a task'
    }
  })
 
  // DELETE /tasks/:id - Delete a task
  .delete('/:id', ({ params, status }) => {
    const deleted = taskStore.delete(params.id)
 
    if (!deleted) {
      return status(404, {
        error: 'Task not found',
        id: params.id
      })
    }
 
    return { success: true, id: params.id }
  }, {
    params: 'task.params',
    detail: {
      tags: ['Tasks'],
      summary: 'Delete a task'
    }
  })

Notice how each route handler uses the model names ('task.create', 'task.params', etc.) instead of inline schemas. This keeps routes clean and ensures consistency.

Step 5: Add Authentication with Guards

ElysiaJS guards let you protect groups of routes with shared validation and hooks. Create src/plugins/auth.ts:

import { Elysia } from 'elysia'
import { bearer } from '@elysiajs/bearer'
 
// In production, use a proper JWT library and database
const VALID_TOKENS = new Set([
  'demo-token-2026'
])
 
export const authPlugin = new Elysia({ name: 'Plugin.Auth' })
  .use(bearer())
  .derive(({ bearer }) => {
    return {
      isAuthenticated: bearer ? VALID_TOKENS.has(bearer) : false
    }
  })
  .macro({
    requireAuth(enabled: boolean) {
      if (!enabled) return
 
      return {
        beforeHandle({ isAuthenticated, status, set }) {
          if (!isAuthenticated) {
            set.headers['WWW-Authenticate'] = 'Bearer realm="task-api"'
            return status(401, {
              error: 'Unauthorized',
              message: 'Valid Bearer token required'
            })
          }
        }
      }
    }
  })

The hardcoded token above is for demonstration only. In production, use JWT tokens with proper signing, expiration, and a real user database.

Now update src/routes/tasks.ts to use the auth guard for write operations. Add the auth plugin and protect mutating routes:

import { Elysia, t } from 'elysia'
import { taskModel } from '../models/task.model'
import { taskStore } from '../models/store'
import { authPlugin } from '../plugins/auth'
 
export const taskRoutes = new Elysia({ prefix: '/tasks' })
  .use(taskModel)
  .use(authPlugin)
 
  // GET routes remain public (no requireAuth)
  .get('/', ({ query }) => {
    // ... same as before
  }, {
    // ... same config
  })
 
  .get('/:id', ({ params, status }) => {
    // ... same as before
  }, {
    // ... same config
  })
 
  // POST, PATCH, DELETE now require authentication
  .post('/', ({ body }) => {
    // ... same handler
  }, {
    body: 'task.create',
    response: 'task.response',
    requireAuth: true,  // Protected!
    detail: {
      tags: ['Tasks'],
      summary: 'Create a new task',
      security: [{ bearer: [] }]
    }
  })
 
  .patch('/:id', ({ params, body, status }) => {
    // ... same handler
  }, {
    params: 'task.params',
    body: 'task.update',
    requireAuth: true,  // Protected!
    detail: {
      tags: ['Tasks'],
      summary: 'Update a task',
      security: [{ bearer: [] }]
    }
  })
 
  .delete('/:id', ({ params, status }) => {
    // ... same handler
  }, {
    params: 'task.params',
    requireAuth: true,  // Protected!
    detail: {
      tags: ['Tasks'],
      summary: 'Delete a task',
      security: [{ bearer: [] }]
    }
  })

The requireAuth: true macro cleanly separates auth logic from business logic. No middleware chains, no next() — just declarative configuration.

Step 6: Wire Everything Together

Update src/index.ts to compose all the pieces:

import { Elysia } from 'elysia'
import { swagger } from '@elysiajs/swagger'
import { cors } from '@elysiajs/cors'
import { taskRoutes } from './routes/tasks'
 
const app = new Elysia()
  // Global plugins
  .use(cors())
  .use(swagger({
    documentation: {
      info: {
        title: 'Task Management API',
        version: '1.0.0',
        description: 'A type-safe REST API built with ElysiaJS and Bun'
      },
      tags: [
        { name: 'Tasks', description: 'Task CRUD operations' },
        { name: 'Health', description: 'API health checks' }
      ],
      components: {
        securitySchemes: {
          bearer: {
            type: 'http',
            scheme: 'bearer'
          }
        }
      }
    }
  }))
 
  // Health check
  .get('/health', () => ({
    status: 'ok',
    timestamp: new Date().toISOString(),
    runtime: 'bun',
    version: '1.0.0'
  }), {
    detail: {
      tags: ['Health'],
      summary: 'API health check'
    }
  })
 
  // Mount route groups
  .use(taskRoutes)
 
  // Global error handler
  .onError(({ code, error }) => {
    if (code === 'VALIDATION') {
      return {
        error: 'Validation Error',
        message: error.message
      }
    }
 
    return {
      error: 'Internal Server Error',
      message: 'Something went wrong'
    }
  })
 
  .listen(3000)
 
console.log(`🦊 Task API running at ${app.server?.hostname}:${app.server?.port}`)
console.log(`📚 Swagger docs at http://localhost:3000/swagger`)
 
// Export the app type for Eden Treaty
export type App = typeof app

The last line — export type App = typeof app — is the magic ingredient. ElysiaJS infers the complete API type from your route definitions, and Eden Treaty uses this type on the client side.

Step 7: Test the API

Start the server:

bun run src/index.ts

Now test each endpoint:

# Health check
curl http://localhost:3000/health
 
# Create a task (with auth)
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer demo-token-2026" \
  -d '{"title": "Learn ElysiaJS", "priority": "high"}'
 
# List all tasks (public)
curl http://localhost:3000/tasks
 
# Filter by priority
curl "http://localhost:3000/tasks?priority=high"
 
# Get a specific task (replace with actual ID)
curl http://localhost:3000/tasks/<task-id>
 
# Update a task
curl -X PATCH http://localhost:3000/tasks/<task-id> \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer demo-token-2026" \
  -d '{"completed": true}'
 
# Delete a task
curl -X DELETE http://localhost:3000/tasks/<task-id> \
  -H "Authorization: Bearer demo-token-2026"
 
# Try without auth (should get 401)
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Should fail"}'

Open http://localhost:3000/swagger in your browser to see the auto-generated interactive API documentation.

Step 8: Add the Eden Treaty Client

This is where ElysiaJS truly shines. Eden Treaty gives you a fully type-safe HTTP client that infers types directly from your server code — no code generation, no separate schema files.

Install Eden:

bun add @elysiajs/eden

Create src/client.ts:

import { treaty } from '@elysiajs/eden'
import type { App } from './index'
 
// Create a type-safe client
const api = treaty<App>('localhost:3000')
 
async function demo() {
  // ✅ Full auto-completion for path, body, and response
  const { data: tasks } = await api.tasks.get({
    query: { priority: 'high' }
  })
 
  console.log('High-priority tasks:', tasks)
 
  // ✅ TypeScript knows the body shape
  const { data: newTask } = await api.tasks.post({
    title: 'Ship the feature',
    priority: 'high'
  }, {
    headers: {
      authorization: 'Bearer demo-token-2026'
    }
  })
 
  console.log('Created task:', newTask)
 
  // ✅ TypeScript knows this returns Task | error
  if (newTask) {
    const { data: updated } = await api.tasks({ id: newTask.id }).patch({
      completed: true
    }, {
      headers: {
        authorization: 'Bearer demo-token-2026'
      }
    })
 
    console.log('Updated task:', updated)
  }
}
 
demo()

The key insight: if you change a route's response type on the server, TypeScript immediately flags every client call that depends on the old type. No runtime surprises, no integration test needed to catch the mismatch.

Step 9: Lifecycle Hooks and Custom Logic

ElysiaJS provides a rich lifecycle system for cross-cutting concerns. Here are practical examples:

Request Logging

import { Elysia } from 'elysia'
 
export const loggerPlugin = new Elysia({ name: 'Plugin.Logger' })
  .onRequest(({ request }) => {
    console.log(`→ ${request.method} ${new URL(request.url).pathname}`)
  })
  .onAfterResponse(({ request, set }) => {
    console.log(`← ${request.method} ${new URL(request.url).pathname} ${set.status ?? 200}`)
  })

Rate Limiting

import { Elysia } from 'elysia'
 
const requestCounts = new Map<string, { count: number; resetAt: number }>()
 
export const rateLimitPlugin = new Elysia({ name: 'Plugin.RateLimit' })
  .derive(({ request }) => {
    const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
    return { clientIp: ip }
  })
  .onBeforeHandle(({ clientIp, status, set }) => {
    const now = Date.now()
    const window = 60_000 // 1 minute
    const limit = 100
 
    let entry = requestCounts.get(clientIp)
 
    if (!entry || now > entry.resetAt) {
      entry = { count: 0, resetAt: now + window }
      requestCounts.set(clientIp, entry)
    }
 
    entry.count++
 
    set.headers['X-RateLimit-Limit'] = String(limit)
    set.headers['X-RateLimit-Remaining'] = String(Math.max(0, limit - entry.count))
 
    if (entry.count > limit) {
      set.headers['Retry-After'] = String(Math.ceil((entry.resetAt - now) / 1000))
      return status(429, { error: 'Too many requests' })
    }
  })

Add these plugins to your main app:

const app = new Elysia()
  .use(loggerPlugin)
  .use(rateLimitPlugin)
  .use(cors())
  .use(swagger({ /* ... */ }))
  // ... rest of your app

Step 10: Testing with Bun's Built-in Test Runner

Bun includes a test runner — no extra dependencies needed. Create src/__tests__/tasks.test.ts:

import { describe, it, expect, beforeAll } from 'bun:test'
import { treaty } from '@elysiajs/eden'
import { Elysia } from 'elysia'
import { taskRoutes } from '../routes/tasks'
import { taskModel } from '../models/task.model'
 
// Create a test instance
const app = new Elysia()
  .use(taskRoutes)
 
const api = treaty(app)
 
describe('Task API', () => {
  let taskId: string
 
  it('should create a task', async () => {
    const { data, status } = await api.tasks.post({
      title: 'Test Task',
      priority: 'high'
    }, {
      headers: {
        authorization: 'Bearer demo-token-2026'
      }
    })
 
    expect(status).toBe(200)
    expect(data?.title).toBe('Test Task')
    expect(data?.priority).toBe('high')
    expect(data?.completed).toBe(false)
 
    taskId = data!.id
  })
 
  it('should list tasks', async () => {
    const { data } = await api.tasks.get()
 
    expect(data?.total).toBeGreaterThan(0)
  })
 
  it('should filter by priority', async () => {
    const { data } = await api.tasks.get({
      query: { priority: 'high' }
    })
 
    expect(data?.data.every(t => t.priority === 'high')).toBe(true)
  })
 
  it('should update a task', async () => {
    const { data } = await api.tasks({ id: taskId }).patch({
      completed: true
    }, {
      headers: {
        authorization: 'Bearer demo-token-2026'
      }
    })
 
    expect(data?.completed).toBe(true)
  })
 
  it('should return 401 without auth', async () => {
    const { status } = await api.tasks.post({
      title: 'Should fail'
    })
 
    expect(status).toBe(401)
  })
 
  it('should return 404 for missing task', async () => {
    const { status } = await api.tasks({ id: 'nonexistent' }).get()
 
    expect(status).toBe(404)
  })
 
  it('should delete a task', async () => {
    const { data } = await api.tasks({ id: taskId }).delete(undefined, {
      headers: {
        authorization: 'Bearer demo-token-2026'
      }
    })
 
    expect(data?.success).toBe(true)
  })
})

Run the tests:

bun test

Notice how we used treaty(app) directly with the Elysia instance instead of making HTTP requests. ElysiaJS handles this internally, making tests fast and simple — no server startup needed.

ElysiaJS vs Hono vs Express: When to Choose What

FeatureElysiaJSHonoExpress
RuntimeBun-firstMulti-runtimeNode.js
Type SafetyEnd-to-end (Eden)Route-levelManual
ValidationBuilt-in (TypeBox)Zod adapterManual/Joi
OpenAPIAuto-generatedManual/pluginswagger-jsdoc
PerformanceVery fastFastModerate
EcosystemGrowingGrowingMassive
Learning CurveModerateLowLow

Choose ElysiaJS when: You want maximum type safety, are already using Bun, and value automatic API documentation.

Choose Hono when: You need multi-runtime support (Cloudflare Workers, Deno) or prefer a lighter framework.

Choose Express when: You need the largest ecosystem and don't mind manual type setup.

Troubleshooting

Common issues and solutions:

"Cannot find module '@elysiajs/swagger'" Make sure you installed with bun add, not npm install. ElysiaJS plugins are optimized for Bun.

Type errors with model references Ensure .use(taskModel) is called before referencing model names. ElysiaJS resolves types through plugin composition order.

"VALIDATION" errors on valid input Check that your Content-Type: application/json header is set. ElysiaJS strictly validates request content types.

Eden Treaty types showing any Make sure you export type App = typeof app from your server file, and import it as a type-only import: import type { App }.

Next Steps

Now that you have a solid foundation, here are ways to extend this project:

  • Add a database: Replace the in-memory store with SQLite (built into Bun) or use Drizzle ORM
  • JWT authentication: Replace bearer tokens with proper JWT signing and verification using @elysiajs/jwt
  • WebSocket support: ElysiaJS has first-class WebSocket support — add real-time task updates
  • File uploads: Handle multipart forms with built-in support
  • GraphQL: Add a GraphQL endpoint alongside REST using @elysiajs/graphql
  • Deploy to production: ElysiaJS works great on Fly.io, Railway, or any Docker host running Bun

Conclusion

ElysiaJS represents a new approach to building TypeScript APIs. Instead of bolting type safety onto an existing framework, it was designed from the ground up to make types flow naturally from your route definitions through validation to the client.

The combination of Bun's raw speed, TypeBox's validation, automatic OpenAPI generation, and Eden Treaty's type-safe client creates a development experience where the compiler catches integration bugs before they reach production. That's the real power of end-to-end type safety — not just type-checking individual functions, but ensuring the entire request/response chain is correct at compile time.

If you're starting a new Bun project and value type safety, ElysiaJS deserves serious consideration.


Want to read more tutorials? Check out our latest tutorial on Build a Local AI Chatbot with Ollama and Next.js: Complete Guide.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles