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