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

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-apiThis creates a minimal project structure. Let's install the plugins we'll need:
bun add @elysiajs/swagger @elysiajs/bearer @elysiajs/corsYour 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:
- Runtime validators (like Zod)
- TypeScript types (via inference)
- 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 appThe 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.tsNow 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/edenCreate 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 appStep 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 testNotice 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
| Feature | ElysiaJS | Hono | Express |
|---|---|---|---|
| Runtime | Bun-first | Multi-runtime | Node.js |
| Type Safety | End-to-end (Eden) | Route-level | Manual |
| Validation | Built-in (TypeBox) | Zod adapter | Manual/Joi |
| OpenAPI | Auto-generated | Manual/plugin | swagger-jsdoc |
| Performance | Very fast | Fast | Moderate |
| Ecosystem | Growing | Growing | Massive |
| Learning Curve | Moderate | Low | Low |
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.
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

Building REST APIs with Hono and Bun: A Modern Alternative to Express
Learn how to build fast, type-safe REST APIs using Hono framework and Bun runtime. A complete guide from setup to deployment with practical examples.

AI SDK 4.0: New Features and Use Cases
Discover the new features and use cases of AI SDK 4.0, including PDF support, computer use, and more.

Introduction to Model Context Protocol (MCP)
Learn about the Model Context Protocol (MCP), its use cases, advantages, and how to build and use an MCP server with TypeScript.