I was building yet another API when it hit me. Why do we accept so much uncertainty in our code? We write types for our database, types for our requests, and types for our responses, but they rarely talk to each other. The frontend guesses what the backend will send. The backend hopes the database returns what it expects. This disconnect creates bugs, slows development, and frustrates everyone. I decided to find a better way. What if your entire stack, from the database query to the API response, was locked together by a single source of truth? Let me show you what I built.
The answer lies in two tools built for this new era of JavaScript: Elysia.js and DrizzleORM. Elysia is a web framework that treats TypeScript as a core feature, not an afterthought. Drizzle is a database toolkit that speaks TypeScript fluently. When you combine them, you stop writing glue code and start building with confidence. Your editor will tell you if you’re about to make a mistake, long before your users ever see an error.
Have you ever spent an hour debugging an API only to find a typo in a field name? That shouldn’t happen.
Let’s start from the ground up. First, create a new project with Bun. If you haven’t tried Bun yet, it’s a fast all-in-one JavaScript runtime. It runs our code, installs packages, and acts as a test runner. Open your terminal and run these commands.
mkdir my-type-safe-api
cd my-type-safe-api
bun init -y
Now, install the core tools we need. We’ll get Elysia for the server, Drizzle for the database, and a few helpers.
bun add elysia @elysiajs/cors
bun add drizzle-orm postgres
bun add -d drizzle-kit @types/pg
Our project needs structure. I like to organize by concern. Create folders for configuration, database schemas, API routes, and shared types.
src/
├── config/
├── db/
├── routes/
├── types/
└── index.ts
The magic begins with the database. With Drizzle, we define our tables using TypeScript. This isn’t just documentation; it’s executable code that Drizzle uses to generate SQL. Let’s create a simple schema for a blog.
// src/db/schema/posts.ts
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { users } from './users';
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
authorId: integer('author_id').references(() => users.id),
createdAt: timestamp('created_at').defaultNow(),
});
See how authorId references the users table? This creates a real foreign key in the database. Drizzle understands this relationship. Now, how do we turn this schema into a real PostgreSQL table? We use Drizzle Kit to generate a migration.
bun drizzle-kit generate
This command looks at your schema files, compares them to your database, and creates a SQL file to update your database structure. Run the migration, and your database is ready.
bun drizzle-kit migrate
Now for the server. Elysia lets us build endpoints with built-in validation. We define the shape of the data we expect, and Elysia checks every incoming request against it. Let’s create a route to fetch a post by its ID.
// src/routes/posts.ts
import { Elysia, t } from 'elysia';
import { db } from '../config/database';
import { posts } from '../db/schema';
import { eq } from 'drizzle-orm';
export const postRoutes = new Elysia({ prefix: '/posts' })
.get('/:id', async ({ params }) => {
const [post] = await db.select().from(posts).where(eq(posts.id, params.id));
if (!post) {
throw new Error('Post not found');
}
return post;
}, {
params: t.Object({
id: t.Numeric()
})
});
Look at the params object. We tell Elysia that the id parameter must be a number. If someone sends /posts/abc, Elysia automatically sends a 400 error with a clear message. No more manual checks.
But what about creating a new post? We need to validate the request body too. Let’s add a POST route.
.post('/', async ({ body }) => {
const [newPost] = await db.insert(posts).values(body).returning();
return newPost;
}, {
body: t.Object({
title: t.String({ minLength: 1 }),
content: t.String({ minLength: 1 }),
authorId: t.Number()
})
});
The t.Object defines the required fields and their types. minLength: 1 ensures the fields aren’t empty. This validation runs before your function code does. It’s a guard at the gate.
Here’s a question: what happens to the types we just defined? Can we use them elsewhere?
This is where Elysia’s Eden Treaty comes in. It’s a feature that creates a fully type-safe client for your API. You import a type from your server and use it in your frontend. Your editor will know the exact shape of every endpoint. Let’s set it up.
First, we need to export the type of our Elysia app.
// src/index.ts
import { Elysia } from 'elysia';
import { postRoutes } from './routes/posts';
const app = new Elysia()
.use(postRoutes)
.listen(3000);
export type App = typeof app;
Now, in your frontend project (like a React app), you install the Elysia client and import this type.
// frontend/src/api/client.ts
import { treaty } from '@elysiajs/eden';
import type { App } from '../../backend/src/index'; // Path to your backend type
const client = treaty<App>('http://localhost:3000');
// Now you can use it with full type safety!
async function fetchPost() {
const { data, error } = await client.posts({ id: 1 }).get();
if (error) {
console.error(error.value);
return;
}
// `data` is fully typed as a Post object!
console.log(data.title);
}
When you type data., your editor will suggest id, title, content, and authorId. It knows because it read the type from your server. If you change the posts schema on the backend, the frontend types will show an error until you update your code. This is the dream of type safety realized.
But we’re not just fetching single items. A real application needs to join data. How do we get a post with its author’s name? Drizzle makes relational queries simple and type-safe.
// In a service file
import { db } from '../config/database';
import { posts, users } from '../db/schema';
import { eq } from 'drizzle-orm';
async function getPostWithAuthor(postId: number) {
const result = await db
.select({
post: posts,
authorName: users.name,
})
.from(posts)
.where(eq(posts.id, postId))
.leftJoin(users, eq(posts.authorId, users.id));
return result[0]; // Type is { post: Post, authorName: string | null }
}
The result is not a mysterious object. It’s explicitly typed as an object with a post and an authorName. You get autocomplete for result[0].post.title. This clarity transforms how you work with data.
What about errors? A robust API needs consistent error handling. We can create a simple middleware in Elysia to catch errors and format them nicely.
// src/middleware/error.ts
import { Elysia } from 'elysia';
export const errorPlugin = new Elysia()
.onError(({ code, error, set }) => {
console.error(error);
if (code === 'VALIDATION') {
set.status = 400;
return { error: 'Invalid request data', details: error.message };
}
if (code === 'NOT_FOUND') {
set.status = 404;
return { error: 'Resource not found' };
}
// Default to 500 for unexpected errors
set.status = 500;
return { error: 'Internal server error' };
});
Plug this into your main app, and all your routes gain this error handling. It turns messy crashes into clean JSON responses.
Let’s talk about putting this into production. You’ll need to connect to a real PostgreSQL database. Use environment variables for configuration. Here’s a pattern I use.
// src/config/database.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../db/schema';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set');
}
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
For deployment, you can use Docker. Create a Dockerfile to bundle your Bun application and a docker-compose.yml to run it alongside PostgreSQL. This setup is reliable and easy to scale.
The combination of Elysia and Drizzle changes how you think about building APIs. You spend less time writing validation logic and debugging type mismatches. You spend more time building features. The feedback loop is immediate and precise. Your tools work together to prevent mistakes.
I started this journey tired of the guesswork. I finished with a stack that feels coherent and solid. The types flow from the database, through the API, and into the frontend. There are no gaps. If you’re building a new API or refactoring an old one, I urge you to try this approach. It might just change your mind about what’s possible with TypeScript.
What will you build with this foundation? I’d love to hear your thoughts and see what you create. If this guide helped you, please share it with someone else who might be fighting the same type wars. Drop a comment below with your experience or any questions. 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