Why Bun + ElysiaJS Feels Like Writing Rust (And Why That's a Good Thing)
$ bun run dev
# finally, a backend that doesn't fight meI’ve been writing Rust for a while now. It’s become my default lens — the way I think about memory, correctness, and system design. When I pick up a new tool, I find myself unconsciously asking: does this fight me, or does it work with me?
Most JavaScript/TypeScript backend frameworks fight me. Not intentionally — they’re just built on a different set of assumptions. Express.js is flexible but loose. NestJS is structured but heavy. Fastify is fast but still feels like you’re navigating around the type system rather than through it.
Then I shipped a production project using Bun, ElysiaJS, and Drizzle ORM — and something clicked.
This isn’t a getting-started tutorial. There are plenty of those. This is the story of what surprised me, what felt familiar, and why I think this stack is quietly doing something philosophically different from the TypeScript ecosystem that came before it.
The Rust Mindset (Bear With Me)
Before I talk about Bun and Elysia, let me explain the Rust mental model I carry into everything.
Rust’s party trick isn’t speed — it’s the borrow checker. The borrow checker is an unforgiving, compile-time system that enforces one single rule: at any point in your program, a value is either owned by one place, or borrowed by multiple readers, but never both simultaneously in a way that could cause a conflict.
What does this buy you? It makes an entire class of bugs impossible to express. You don’t catch them in tests. You don’t catch them in staging. You can’t even finish writing them — the compiler stops you mid-thought.
This is the property I find myself chasing in every language I use: if I write incorrect code, I want the system to tell me before it runs.
TypeScript gestures at this. It has types. But TypeScript’s type system, especially in web frameworks, has historically been porous. Types often exist at the boundary of your code (function signatures, API schemas) but disappear in the middle. You’d define an interface, pass it around, and somewhere in the middleware chain, it quietly becomes any. The runtime would disagree with the types. You’d discover this in production.
Elysia changed something about that equation for me.
Meeting Elysia
I came to ElysiaJS skeptically. I’d seen other “type-safe” frameworks before. They usually meant: we have TypeScript and we wrote some decorators.
Elysia is different in a way that takes a moment to see.
Here’s a simple route:
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/devices/register', ({ body }) => {
// body is fully typed here — no casting, no inference gaps
return registerDevice(body.deviceId, body.location)
}, {
body: t.Object({
deviceId: t.String(),
location: t.Object({
lat: t.Number(),
lng: t.Number()
})
})
})The schema you define with t (powered by TypeBox) isn’t just for runtime validation. Elysia uses it to derive the TypeScript type of body at compile time. You write the shape once, and both the runtime guard and the IDE autocomplete come from the same source.
If you try to access body.deviceId as a number, TypeScript will stop you. If a request arrives with deviceId missing, Elysia rejects it before your handler ever runs.
This is the borrow checker’s spirit: a class of errors that simply cannot reach runtime.
The Type-Level Contract
When I build IoT systems in Rust, every message that passes between a microcontroller and a backend is typed, versioned, and verified — usually via Protocol Buffers. I’m paranoid about shape mismatches. A sensor reporting { temp: "22.5" } instead of { temp: 22.5 } is a subtle bug that becomes a corrupted time-series dataset at 3am.
Working with ElysiaJS in production, I noticed I had the same level of confidence I get from a Rust deserialization step. The request comes in. Elysia validates it against the schema. If it passes, the shape is guaranteed — I don’t need to check again inside my handler. If it doesn’t pass, the client gets a 422 before my business logic is touched.
In Express, I’d manually write a Zod schema, then manually call schema.parse(req.body), then manually propagate the typed result. Three steps, each a potential gap. In Elysia, the schema is the route definition. They’re inseparable.
That reduction in “manual plumbing” is the same satisfaction I get from Rust’s #[derive(Deserialize)] — the machinery is there, it’s correct, and I don’t have to think about it.
Eden Treaty: The Client Side of the Contract
Here’s where it gets genuinely novel.
Elysia ships a client library called Eden Treaty. Once you export your app’s type, Eden Treaty can consume it on any TypeScript client — frontend, another service, a CLI — and give you fully typed, autocompleted HTTP calls.
// server.ts
export type App = typeof app
// client.ts (another service, or your frontend)
import { treaty } from '@elysiajs/eden'
import type { App } from '../server'
const client = treaty<App>('http://localhost:3000')
const { data, error } = await client.devices.register.post({
deviceId: 'esp32-001',
location: { lat: -7.75, lng: 110.4 }
})
// `data` is fully typed — no manual interface duplicationI had a moment of genuine disbelief when I set this up in production. The response type — not just the request type — is inferred from the server code. If I change the shape of what a handler returns, the client code breaks at compile time.
This is end-to-end type safety, and it’s not achieved through code generation, a separate schema language, or a build step. It’s just TypeScript doing what TypeScript is actually capable of when a framework is designed around it from the start.
The Rust parallel here is trait bounds — the idea that you can make promises about an interface at the type level, and the compiler holds both sides of that promise. Eden Treaty does this across a network boundary. That’s philosophically remarkable.
Bun: Performance as a Value
Now let’s talk about Bun, because the type safety story would mean less if the runtime itself was slow.
I run Rust on the backend of my IoT systems partly because I need deterministic, low-latency processing. When sensor data arrives in bursts — 200 devices reporting every 500ms — I can’t afford a garbage collector pause at the wrong moment, or a slow JSON parser, or a startup time measured in seconds.
When I moved the API layer of that same system to Bun, I wasn’t expecting much. Node.js is fast enough for HTTP work. But Bun surprised me.
Bun is built on JavaScriptCore (the engine from Safari), written in Zig, and aggressively optimized at the I/O layer. In my production system:
- Cold start time dropped from ~400ms (Node.js + ts-node) to under 40ms
- JSON parsing throughput roughly doubled for large payloads
- Fetch and native HTTP performance is measurably faster under load
These aren’t benchmark numbers I found on a blog — these are numbers I observed in my own monitoring (yes, using my otel-rs-inspired observability setup, ported to TypeScript for this service).
The broader point is this: Bun treats performance as a design value, not an afterthought. In that way, it shares DNA with Rust. The Bun team is writing tight, low-level code so that you don’t have to think about it. You just write TypeScript, and the runtime is fast.
Drizzle: SQL That Tells the Truth
If Elysia is the borrow checker for your HTTP layer, Drizzle ORM is the borrow checker for your database layer.
ORMs are famously a source of type lies. You define a model, but the runtime query result might have nulls where you didn’t expect them, or joined fields that aren’t in your type, or missing fields from a partial select. The ORM’s types are often aspirational — they describe what could come back, not what will come back from this specific query.
Drizzle takes a different approach: it generates TypeScript types directly from your schema, and the types it infers for query results are derived from the actual query you wrote.
// schema.ts
import { pgTable, serial, text, real, timestamp } from 'drizzle-orm/pg-core'
export const sensorReadings = pgTable('sensor_readings', {
id: serial('id').primaryKey(),
deviceId: text('device_id').notNull(),
temperature: real('temperature').notNull(),
humidity: real('humidity'),
recordedAt: timestamp('recorded_at').defaultNow()
})When I query this table:
const readings = await db
.select({
deviceId: sensorReadings.deviceId,
temperature: sensorReadings.temperature
})
.from(sensorReadings)
.where(eq(sensorReadings.deviceId, 'esp32-001'))The inferred type of readings is { deviceId: string; temperature: number }[] — not the full row type. It knows humidity isn’t in the result because I didn’t select it. This matters when I’m building aggregation pipelines and I want to be sure I’m not accidentally accessing a field I didn’t fetch.
Drizzle also doesn’t abstract SQL away. You write queries that look like SQL, because they are. This means there are no “magic” behaviors where the ORM generates a query you didn’t expect. You see what you’re sending to the database. For a systems programmer, this transparency is deeply reassuring.
Putting It Together in Production
Let me show you how these three pieces compose in a real-world scenario from my production system: an endpoint that receives sensor data from ESP32 devices and stores it.
import { Elysia, t } from 'elysia'
import { db } from './db'
import { sensorReadings } from './schema'
const app = new Elysia()
.post('/telemetry', async ({ body }) => {
await db.insert(sensorReadings).values({
deviceId: body.deviceId,
temperature: body.temperature,
humidity: body.humidity ?? null,
})
return { ok: true, received: body.deviceId }
}, {
body: t.Object({
deviceId: t.String({ minLength: 1 }),
temperature: t.Number({ minimum: -50, maximum: 100 }),
humidity: t.Optional(t.Number({ minimum: 0, maximum: 100 }))
})
})
.listen(3000)
export type App = typeof appWhat I want you to notice:
- The schema validates at the boundary. An ESP32 sending a malformed payload never reaches the database layer. No try-catch needed around parsing.
humidityis optional in both the schema and the insert. TypeScript knows this — if I accidentally tried to dobody.humidity.toFixed(2)without a null check, the compiler would stop me.- The Drizzle insert is type-checked against the table schema. If I tried to insert a field that doesn’t exist in
sensorReadings, I’d get a compile-time error. - The exported
Apptype lets any client call this endpoint with full type safety, without me writing a separate interface or generating code.
The entire data path — from network request to database write — is covered by types that are derived from a single source of truth. This is what Rust taught me to want, and this stack delivers it in TypeScript.
Where the Analogy Breaks Down
I want to be honest here, because a Rust developer reading this might be tempted to think this is the same thing. It’s not.
TypeScript’s type system is erased at runtime. Bun still runs JavaScript under the hood. If you bypass Elysia’s schema (say, by using a raw Bun serve()) and pass in unvalidated data, nothing stops you. The type safety is opt-in, not enforced by the runtime.
In Rust, the borrow checker cannot be bypassed (without unsafe, which is explicit and auditable). In Elysia, you can write (body as any).whatever and the type system will let you. The discipline is yours to maintain.
That said, the ergonomics of doing the right thing are so good in this stack that I find myself never reaching for escape hatches. The schemas are easy to write. The type inference is automatic. There’s no friction that would tempt me toward sloppiness.
Rust taught me that the best safety mechanisms are the ones you forget are there — because the right path is also the easy path. ElysiaJS makes the type-safe path the path of least resistance. That’s not the same as Rust, but it’s a meaningful step in that direction.
Why I Think This Matters
The TypeScript ecosystem has been on a slow, decade-long journey toward taking types seriously. We went from “JavaScript with type hints” to Zod and tRPC showing that runtime validation and compile-time types can be unified. Elysia is the next step on that path — it’s a framework designed from the ground up around the idea that the type system should do real work.
For developers coming from Rust, Go, or other statically typed systems, the TypeScript ecosystem has historically felt like a step backward. You had types, but they were full of holes. You had frameworks, but they were built for flexibility at the cost of correctness.
This stack — Bun for runtime performance, Elysia for type-safe HTTP, Drizzle for type-safe data access — represents a version of TypeScript development that I can actually respect. It doesn’t feel like I’m compromising. It feels like I’m using the right tool for the job.
If you’re a systems programmer who has avoided TypeScript web development because it felt sloppy, I’d encourage you to spend a day with this stack. Not because it’s Rust — it’s not. But because it’s trying to be honest in the same way Rust is honest: it makes incorrect code harder to write, and correct code feel natural.
Getting Started
If you want to try this yourself, here’s the minimal setup:
# Install Bun
curl -fsSL https://bun.sh/install | bash
# Create a new project
mkdir my-api && cd my-api
bun init
# Install dependencies
bun add elysia @elysiajs/eden drizzle-orm
bun add -d drizzle-kitFor the database, you’ll need a PostgreSQL driver. Drizzle supports pg, postgres, and others. If you’re on Bun, bun:sqlite works out of the box for local development.
From there, the Elysia documentation is excellent, and Drizzle’s schema declaration guide will get you set up quickly.
Closing Thoughts
I shipped this stack to production. It’s running. It handles real sensor data from real hardware. And the thing I notice most, day to day, is how rarely I’m surprised by a bug that “shouldn’t be possible” given my types.
That’s the Rust promise: a system where the compiler eliminates whole categories of mistakes before they become runtime errors. This stack doesn’t fully deliver that promise — TypeScript never will — but it gets meaningfully closer than anything I’ve used before in the JavaScript world.
Sometimes the best tool for a job is the one that makes you feel like it’s on your side.
Working on a similar hardware project or have questions about the str0m integration? Reach out — qcynaut@gmail.com