js

Build Type-Safe APIs with Elysia.js and Bun: A Complete Guide

Discover how to create blazing-fast, fully type-safe APIs using Elysia.js and Bun with TypeBox validation.

Build Type-Safe APIs with Elysia.js and Bun: A Complete Guide

I was building an API recently, and something kept bothering me. I had all my TypeScript types defined, but the moment a request hit my route, that safety vanished. The request body was an any type, query parameters were a mystery, and I was back to writing manual validation. It felt like taking two steps forward and one step back. That frustration led me down a rabbit hole, and I found a combination that changed everything: Elysia.js running on Bun.

Think about the last API you built. How much time did you spend writing validation logic? How many bugs slipped through because a field was missing or the wrong type? What if you could guarantee that every piece of data flowing through your application, from the client request to the database and back, was exactly what you expected? That’s the promise we’re going to explore today.

Let’s get started. First, you need Bun. It’s not just another Node.js alternative; it’s a complete toolkit. Open your terminal.

# On macOS or Linux
curl -fsSL https://bun.sh/install | bash

# On Windows, use PowerShell
powershell -c "irm bun.sh/install.ps1 | iex"

Once it’s installed, creating a new project is straightforward.

mkdir my-elysia-api && cd my-elysia-api
bun init -y
bun add elysia

Now, create a file named index.ts. This will be our server’s entry point. We’ll start simple.

import { Elysia } from 'elysia';

const app = new Elysia()
  .get('/', () => 'Server is running!')
  .listen(3000);

console.log(`🦊 App is live on http://localhost:3000`);

Run it with bun run index.ts. In under a second, you have a live server. Notice the speed? That’s Bun’s native performance. But a “Hello World” isn’t useful. Let’s build something real.

We’ll create a simple API for a task manager. We need to define what a task looks like. This is where type-safety begins. Instead of writing interfaces that only exist in our editor, we’ll use a library called TypeBox to create schemas that both validate data and give us TypeScript types.

bun add @sinclair/typebox

Create a new file: src/schemas/task.schema.ts. Here, we define the shape of our data.

import { Type, Static } from '@sinclair/typebox';

export const TaskSchema = Type.Object({
  id: Type.String({ format: 'uuid' }),
  title: Type.String({ minLength: 1, maxLength: 255 }),
  description: Type.Optional(Type.String()),
  isCompleted: Type.Boolean({ default: false }),
  createdAt: Type.String({ format: 'date-time' }),
});

export type Task = Static<typeof TaskSchema>;

export const CreateTaskSchema = Type.Omit(TaskSchema, ['id', 'createdAt']);
export type CreateTaskDTO = Static<typeof CreateTaskSchema>;

See what we did? TaskSchema describes a full task from the database. CreateTaskSchema uses Type.Omit to define what the client should send when creating a new task—no id or createdAt. The Static helper gives us a pure TypeScript type from the schema. One source of truth.

Now, let’s connect this schema to a route. Back in index.ts, we’ll expand our app.

import { Elysia } from 'elysia';
import { CreateTaskSchema, TaskSchema } from './src/schemas/task.schema';

// A simple in-memory store for now
const taskStore: Map<string, Task> = new Map();

const app = new Elysia()
  .get('/', () => 'Task API Ready')
  .post('/tasks', ({ body }) => {
    const newTask: Task = {
      id: crypto.randomUUID(),
      createdAt: new Date().toISOString(),
      ...body,
    };
    taskStore.set(newTask.id, newTask);
    return newTask;
  }, {
    body: CreateTaskSchema,    // Validate the request body
    response: TaskSchema,      // Define the response shape
  })
  .get('/tasks', () => {
    return Array.from(taskStore.values());
  })
  .listen(3000);

The magic is in the route definition. The body: CreateTaskSchema tells Elysia to validate the incoming request against our schema. If the client sends a title longer than 255 characters or a number instead of a string, Elysia automatically rejects the request with a clear error. Inside the handler, the body parameter is fully typed as CreateTaskDTO. No type assertions needed.

But what about getting a single task? We need a route parameter. How do we ensure the id in the URL is a valid UUID? Elysia handles that too.

.get('/tasks/:id', ({ params }) => {
    const task = taskStore.get(params.id);
    if (!task) {
      throw new Error('Task not found');
    }
    return task;
  }, {
    params: Type.Object({
      id: Type.String({ format: 'uuid' }),
    }),
    response: TaskSchema,
  })

The params option validates the route parameters. A request to /tasks/not-a-uuid will fail before it even reaches our logic. This proactive validation eliminates a whole class of common bugs.

Let’s add a database to make this persistent. Bun has a built-in SQLite module, which is perfect for this example. We’ll set it up in a separate file.

// src/db.ts
import { Database } from 'bun:sqlite';

const db = new Database('tasks.db');

// Initialize the table
db.run(`
  CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    description TEXT,
    isCompleted BOOLEAN DEFAULT 0,
    createdAt TEXT NOT NULL
  )
`);

export { db };

Now, we can refactor our POST route to use the database. Notice how our business logic doesn’t get cluttered with validation code. It stays clean and focused.

import { db } from './src/db';

.post('/tasks', async ({ body }) => {
    const newTask: Task = {
      id: crypto.randomUUID(),
      createdAt: new Date().toISOString(),
      ...body,
    };

    const stmt = db.prepare(
      'INSERT INTO tasks (id, title, description, isCompleted, createdAt) VALUES (?, ?, ?, ?, ?)'
    );
    stmt.run(
      newTask.id,
      newTask.title,
      newTask.description || null,
      newTask.isCompleted ? 1 : 0,
      newTask.createdAt
    );

    return newTask;
  }, {
    body: CreateTaskSchema,
    response: TaskSchema,
  })

We’ve built a fully type-safe create operation. But can we trust our other routes? Let’s improve the GET endpoints. We should add query parameters for filtering, like getting only completed tasks. This needs another schema.

// In task.schema.ts
export const TaskQuerySchema = Type.Object({
  isCompleted: Type.Optional(Type.String({ enum: ['true', 'false'] })),
});
export type TaskQuery = Static<typeof TaskQuerySchema>;

Now, update the GET /tasks route.

.get('/tasks', ({ query }) => {
    let sql = 'SELECT * FROM tasks';
    const params = [];

    if (query.isCompleted) {
      sql += ' WHERE isCompleted = ?';
      params.push(query.isCompleted === 'true' ? 1 : 0);
    }

    const stmt = db.prepare(sql);
    const rows = stmt.all(...params) as Task[];
    return rows;
  }, {
    query: TaskQuerySchema, // Validates ?isCompleted=true
  })

The query object is now typed. You get autocomplete for isCompleted in your code editor. If a user passes isCompleted=yes, it’s automatically rejected. Your application logic only deals with valid data.

This approach transforms how you write backend code. You stop being a detective, constantly checking and guarding against invalid input. You start being an architect, defining clear contracts and letting the framework enforce them. The mental load decreases significantly.

Imagine extending this. Adding user authentication. You would create a UserSchema and a middleware to validate a JWT. The user’s ID, parsed from a valid token, would become a typed property on the request context. Every subsequent handler would know, for certain, that a user is logged in and who they are. That certainty is powerful.

We’ve only scratched the surface. Elysia can generate OpenAPI documentation from these schemas automatically, turning your type definitions into interactive API docs. It supports WebSockets with the same type-safety. The ecosystem is built around this core idea: your types are not just documentation; they are active, enforcing participants in your application’s runtime.

The combination of Bun’s raw speed and Elysia’s type-centric design creates a feedback loop. You write less boilerplate, your code is more robust, and you can iterate faster. It turns the often-tedious process of API development into a smooth, predictable workflow.

So, what will you build with this foundation? A robust backend for a mobile app? A high-performance microservice? The tools are now in your hands. Start by defining your data, then let the types guide you. The safety and speed you gain are not just incremental improvements; they change the way you think about building for the web.

I’d love to hear what you create. Did this approach solve a pain point for you? Have you found other ways to enforce type safety? Share your thoughts and experiences in the comments below. If this guide helped you, please consider liking and sharing it with other developers who might be facing the same challenges. Let’s build more reliable software, together.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: elysiajs,bun,typescript api,typebox,type-safe backend



Similar Posts
Blog Image
Complete Guide to React Server-Side Rendering with Fastify: Setup, Implementation and Performance Optimization

Learn to build fast, SEO-friendly React apps with server-side rendering using Fastify. Complete guide with setup, hydration, routing & deployment tips.

Blog Image
Build a High-Performance GraphQL API with Fastify Mercurius and Redis Caching Tutorial

Build a high-performance GraphQL API with Fastify, Mercurius & Redis caching. Learn advanced optimization, data loaders, and production deployment strategies.

Blog Image
Building Event-Driven Microservices with NestJS: Complete Guide to RabbitMQ, MongoDB, and Saga Patterns

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master Saga patterns, error handling & deployment strategies.

Blog Image
How to Scale React Apps with Webpack Module Federation and Micro-Frontends

Discover how to break up monolithic React apps using Webpack Module Federation for scalable, independent micro-frontend architecture.

Blog Image
Build Distributed Task Queue System with BullMQ Redis TypeScript Complete Production Guide

Learn to build scalable distributed task queues with BullMQ, Redis, and TypeScript. Complete guide covers setup, scaling, monitoring & production deployment. Start building today!

Blog Image
Build Production-Ready APIs: Fastify, Prisma, Redis Performance Guide with TypeScript and Advanced Optimization Techniques

Learn to build high-performance APIs using Fastify, Prisma, and Redis. Complete guide with TypeScript, caching strategies, error handling, and production deployment tips.