js

Build End-to-End Type-Safe APIs with Bun, Elysia.js, and Drizzle ORM

Eliminate runtime type bugs by connecting your database, backend, and frontend with full type safety using Bun, Elysia, and Drizzle.

Build End-to-End Type-Safe APIs with Bun, Elysia.js, and Drizzle ORM

I’ve been building web APIs for years, and I’ve felt the frustration. You write code, you test it, and somewhere between the database and the browser, a type mismatch slips through. A user ID that’s a string instead of a number. A missing field that crashes the frontend. It’s a constant game of whack-a-mole. This week, I decided to stop playing that game. I set out to build an API where the types are so strict, so connected from the database all the way to the HTTP response, that many common bugs become impossible. The tools I chose? Elysia.js, Drizzle ORM, and the Bun runtime. Let me show you what I built.

Why these three? Each one solves a specific, painful problem. Bun is incredibly fast. Starting a server takes milliseconds, not seconds. It handles requests much quicker than Node.js. Elysia.js is a framework built for Bun. Its entire design is about type safety. You define what a request should look like, and it ensures the response matches, automatically. Drizzle ORM is different from other database tools. It doesn’t try to hide SQL. It gives you a clean, type-safe way to write queries, and your database structure is defined in TypeScript. Put them together, and you get a pipeline where your database schema informs your API types, which inform your frontend types. Everything is connected.

Let’s start by setting up. First, you need Bun. If you haven’t tried it yet, the installation is simple. Open your terminal and run: curl -fsSL https://bun.sh/install | bash. Once it’s installed, creating a new project is a one-liner: bun create elysia my-api. This sets up a basic Elysia project. Now, let’s add our database tools. Run bun add drizzle-orm postgres and bun add -d drizzle-kit. Drizzle Kit will help us manage our database migrations.

Have you ever spent hours debugging an API only to find the issue was in the database connection? Let’s get that right from the start. Create a .env file in your project root. Add your database connection string: DATABASE_URL="postgresql://user:password@localhost:5432/myapp". Now, create a db folder. Inside, we’ll make our schema. This is where the magic begins. With Drizzle, your database tables are defined as TypeScript objects.

Here’s how you might define a simple users table:

// src/db/schema.ts
import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 100 }),
  createdAt: timestamp('created_at').defaultNow(),
});

See how that looks? It’s clear and readable. The serial('id') defines an auto-incrementing integer. The varchar defines a string with a maximum length. This isn’t just documentation; this is executable code that Drizzle uses to understand your database. From this, TypeScript can infer the exact shape of a user object: { id: number, email: string, name: string | null, createdAt: Date }.

But a schema alone is just a blueprint. We need to create the actual tables in PostgreSQL. This is where migrations come in. Drizzle Kit can generate the SQL for you. Configure it by creating a drizzle.config.ts file:

import type { Config } from 'drizzle-kit';
export default {
  schema: './src/db/schema.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: { url: process.env.DATABASE_URL! },
} satisfies Config;

Then, run bun drizzle-kit generate. This command looks at your schema.ts file, compares it to your database (if it exists), and creates SQL files in the ./drizzle/migrations folder. To run these migrations and update your database, you use bun drizzle-kit migrate. It’s a clean, predictable process. No more manually writing CREATE TABLE statements and hoping you didn’t make a typo.

Now, with our database ready, let’s connect it to Elysia. We need to set up a database client. Create a file like src/db/index.ts:

import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool, { schema });

We import our schema and pass it to Drizzle. This is crucial. It tells Drizzle about our table structures and their relationships, which enables full type inference on every query we write.

Let’s build our first API endpoint. In Elysia, you start by creating an app instance. But Elysia’s real power is in its hooks and schemas. You can define the expected shape of a request body, query parameters, or response. Let’s create a POST /users endpoint to register a new user.

// src/index.ts
import { Elysia, t } from 'elysia';
import { db } from './db';
import { users } from './db/schema';

const app = new Elysia()
  .post('/users', async ({ body }) => {
    // Insert the new user and get back their data
    const [newUser] = await db.insert(users).values(body).returning();
    return { success: true, user: newUser };
  }, {
    body: t.Object({
      email: t.String({ format: 'email' }),
      name: t.Optional(t.String({ minLength: 1 })),
    })
  })
  .listen(3000);

Look at the body specification. t.Object({...}) comes from Elysia’s built-in type system. It says the request body must be an object with an email field that is a valid email string, and an optional name field. If someone sends invalid data, Elysia automatically returns a 400 Bad Request error with details before our handler function even runs. The db.insert(users).values(body) line is fully type-safe. TypeScript knows that body should match the users table structure, and it will complain if you try to insert a field that doesn’t exist.

What about fetching data? Let’s add a GET /users endpoint. This is where Drizzle’s query builder feels like writing SQL, but with autocomplete.

app.get('/users', async ({ query }) => {
  const { limit = 20, offset = 0 } = query;
  const userList = await db.select().from(users).limit(limit).offset(offset);
  return userList;
}, {
  query: t.Object({
    limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100 })),
    offset: t.Optional(t.Numeric({ minimum: 0 })),
  })
});

Notice the query validation? We’re ensuring limit and offset are numbers within sensible bounds. And the db.select().from(users) query? The type of userList is automatically inferred as an array of the user type from our schema. No manual interfaces needed.

But here’s a question: what happens when your business logic gets more complex? You need to join tables. Let’s say we have a posts table linked to users. Fetching a post with its author’s name is straightforward and still type-safe.

// In your schema
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title').notNull(),
  authorId: integer('author_id').references(() => users.id),
});

// In your endpoint
app.get('/posts/:id', async ({ params }) => {
  const result = await db.select({
    postId: posts.id,
    title: posts.title,
    authorName: users.name,
  })
  .from(posts)
  .leftJoin(users, eq(posts.authorId, users.id))
  .where(eq(posts.id, params.id));

  return result[0];
});

The result variable will have the type { postId: number, title: string, authorName: string | null }[]. The types flow from the schema, through the query, into your API response. This end-to-end safety is what saves you from runtime surprises.

I also wanted automatic API documentation. Elysia has a plugin for that. Just add @elysiajs/swagger:

import { swagger } from '@elysiajs/swagger';

const app = new Elysia()
  .use(swagger())
  .get('/users', () => { /* handler */ })
  .listen(3000);

Now, if you go to http://localhost:3000/swagger, you’ll find a fully interactive OpenAPI page. All your endpoints, their expected parameters, and response shapes are documented. And it was generated from the same type definitions you use in your code. If you change a parameter type, the documentation updates automatically.

After building a few endpoints like this, the workflow feels solid. You define your data structure in one place (schema.ts). You write queries with confidence because the compiler checks them. You build endpoints with built-in validation. The feedback loop is tight, and the safety net is strong. It’s a different way of thinking about API development, where the types are your guide, not an afterthought.

This combination of Bun, Elysia, and Drizzle isn’t just about new tools. It’s about a smoother, more reliable way to build. You spend less time debugging type errors and more time building features. The performance from Bun is a bonus, but the real win is the developer experience. Give it a try on your next project. Start small, define one table, and build one endpoint. Feel how the types connect. I think you’ll find it hard to go back.

If this approach to building type-safe APIs resonates with you, or if you have a different strategy, I’d love to hear about it. Share your thoughts in the comments below. And if you found this guide helpful, please pass it along to another developer who might be tired of chasing runtime type bugs. Happy coding


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: type-safe apis,bun runtime,elysia js,drizzle orm,typescript backend



Similar Posts
Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn to integrate Next.js with Prisma ORM for type-safe, full-stack web apps. Complete setup guide with database queries, TypeScript support & best practices.

Blog Image
Build High-Performance GraphQL API: Apollo Server, Prisma ORM, Redis Caching Guide

Learn to build a high-performance GraphQL API with Apollo Server, Prisma ORM, and Redis caching. Complete guide with authentication, subscriptions, and optimization techniques.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Applications with Modern ORM

Learn how to seamlessly integrate Next.js with Prisma ORM for type-safe web apps. Build robust database-driven applications with enhanced developer experience.

Blog Image
Production-Ready Event-Driven Microservices: NestJS, RabbitMQ, and MongoDB Architecture Guide

Learn to build production-ready microservices with NestJS, RabbitMQ & MongoDB. Master event-driven architecture, async messaging & distributed systems.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Applications in 2024

Learn how to integrate Next.js with Prisma ORM for full-stack TypeScript apps. Get type-safe database operations, better performance & seamless development workflow.

Blog Image
Build Type-Safe GraphQL APIs with NestJS, Prisma, and Code-First Development: Complete Guide

Learn to build type-safe GraphQL APIs using NestJS, Prisma & code-first development. Master authentication, performance optimization & production deployment.