🎯 Meta: conocer el stack JavaScript más rápido y moderno de 2026. Bun es un runtime que reemplaza a Node (y a npm, y al bundler, y al test runner) siendo mucho más veloz. Elysia es un framework diseñado para exprimir Bun con type-safety de punta a punta.
Versiones: Bun 1.3.13 · Elysia 1.4 · TypeScript 7.
📘 Este capítulo exprime el tipado de TypeScript. Si te pierdes con los tipos, lee el Apéndice A. (Bun ejecuta TypeScript directamente; para type-check en tu CI, TS 7 lo hace en segundos.)
🗞️ Dato 2026: Bun fue adquirido por Anthropic en diciembre de 2025. Su desarrollo se aceleró.
7.1 · ¿Qué es Bun y por qué importa?
Bun es un runtime de JavaScript/TypeScript (como Node) pero:
- Mucho más rápido: arranque, ejecución e instalación de paquetes.
- Todo-en-uno: runtime + gestor de paquetes (
bun install) + bundler + test runner + ejecuta TypeScript directamente (sin compilar nits-node). - Compatible con Node: usa las mismas APIs y paquetes de npm en su mayoría.
Elysia es un framework web hecho para Bun con una obsesión: type-safety end-to-end. El tipo de tu validación es el tipo de tu handler es el tipo que consume tu cliente. Si cambias el esquema, TypeScript te avisa en toda la cadena.
🧠 Elysia vs NestJS (filosofía opuesta): NestJS es estructurado, con decoradores, DI y mucha ceremonia (ideal para equipos grandes). Elysia es minimalista, funcional y encadenado (ideal para velocidad y proyectos ágiles). Conocer ambos te da criterio para elegir.
7.2 · Instalación
# Instalar Bun (Mac/Linux/WSL):
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell):
powershell -c "irm bun.sh/install.ps1 | iex"
bun --version # 1.3.x
# Crear proyecto Elysia:
bun create elysia tienda-api
cd tienda-api
bun run dev # arranca en http://localhost:30007.3 · "Hola mundo" y el encadenamiento
Elysia se construye encadenando métodos. Esto no es solo estético: cada . va acumulando tipos:
// src/index.ts
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', () => 'Hola, Elysia!')
.get('/salud', () => ({ estado: 'ok' }))
.listen(3000)
console.log(`Corriendo en ${app.server?.hostname}:${app.server?.port}`)Devolver un objeto → JSON automático. Devolver texto → text/plain. Bun ejecuta este TypeScript sin paso de compilación.
7.4 · Validación con esquemas (type-safe de verdad)
Elysia valida con esquemas (TypeBox nativo, o Zod/Valibot vía Standard Schema desde 1.4). Lo mágico: el esquema genera el tipo TypeScript del handler automáticamente.
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/productos',
({ body }) => {
// 'body' ya está tipado y validado según el schema de abajo.
// body.nombre es string, body.precio es number — TypeScript lo sabe.
return { id: 1, ...body }
},
{
body: t.Object({
nombre: t.String({ minLength: 1, maxLength: 255 }),
precio: t.Number({ minimum: 0 }),
stock: t.Optional(t.Number({ minimum: 0, default: 0 })),
}),
}
)
.listen(3000)Si mandan precio: -5, Elysia responde 422 con detalle, solo. Y si en el handler escribes body.precioo (typo), TypeScript no compila. Esa es la diferencia con todo lo anterior: los errores se atrapan al escribir, no en runtime.
💡 Tip:
tes el constructor de esquemas de Elysia.t.Object,t.String,t.Number,t.Array,t.Optional,t.Union… Con ellos describes entrada, salida, params, query y headers, todo tipado.
7.5 · CRUD completo
// src/productos.ts
import { Elysia, t } from 'elysia'
const db = new Map<number, any>()
let seq = 0
const ProductoBody = t.Object({
nombre: t.String({ minLength: 1, maxLength: 255 }),
precio: t.Number({ minimum: 0 }),
stock: t.Optional(t.Number({ minimum: 0 })),
})
export const productos = new Elysia({ prefix: '/productos' })
// Listar
.get('/', () => [...db.values()])
// Ver uno (params.id tipado como number gracias al schema)
.get('/:id', ({ params: { id }, status }) => {
const p = db.get(id)
if (!p) return status(404, { error: 'No encontrado' })
return p
}, { params: t.Object({ id: t.Number() }) })
// Crear
.post('/', ({ body, status }) => {
const producto = { id: ++seq, ...body, disponible: (body.stock ?? 0) > 0 }
db.set(producto.id, producto)
return status(201, producto)
}, { body: ProductoBody })
// Editar
.put('/:id', ({ params: { id }, body, status }) => {
if (!db.has(id)) return status(404, { error: 'No encontrado' })
const p = { ...db.get(id), ...body }
db.set(id, p)
return p
}, { params: t.Object({ id: t.Number() }), body: ProductoBody })
// Borrar
.delete('/:id', ({ params: { id }, status }) => {
if (!db.has(id)) return status(404, { error: 'No encontrado' })
db.delete(id)
return status(204, '')
}, { params: t.Object({ id: t.Number() }) })// src/index.ts
import { Elysia } from 'elysia'
import { productos } from './productos'
new Elysia()
.use(productos) // monta el módulo
.listen(3000)🧠 Módulos en Elysia: cada
new Elysia()es un módulo/plugin que se compone con.use(). Es el equivalente a los blueprints de Flask, routers de FastAPI o módulos de Nest, pero encadenado y tipado.
7.6 · Base de datos con Drizzle ORM
El combo favorito en 2026 es Elysia + Drizzle (ORM ligero, tipado y SQL-first). También puedes usar el SQLite nativo de Bun (bun:sqlite) o Prisma.
bun add drizzle-orm postgres
bun add -d drizzle-kit// src/db/schema.ts
import { pgTable, bigserial, varchar, numeric, integer, boolean, timestamp } from 'drizzle-orm/pg-core'
export const productos = pgTable('productos', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
nombre: varchar('nombre', { length: 255 }).notNull(),
precio: numeric('precio', { precision: 10, scale: 2 }).notNull(),
stock: integer('stock').default(0).notNull(),
activo: boolean('activo').default(true).notNull(),
creadoEn: timestamp('creado_en', { withTimezone: true }).defaultNow().notNull(),
})// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
const client = postgres(process.env.DATABASE_URL!)
export const db = drizzle(client)Consultas (tipadas de punta a punta):
import { db } from './db'
import { productos } from './db/schema'
import { eq } from 'drizzle-orm'
await db.select().from(productos) // SELECT *
await db.select().from(productos).where(eq(productos.id, 1)) // WHERE id = 1
await db.insert(productos).values({ nombre: 'Mouse', precio: '49.90' }).returning()
await db.update(productos).set({ stock: 5 }).where(eq(productos.id, 1))
await db.delete(productos).where(eq(productos.id, 1))Migraciones con drizzle-kit:
bunx drizzle-kit generate # genera SQL a partir del schema
bunx drizzle-kit migrate # aplica a la BD7.7 · Documentación automática (OpenAPI/Swagger)
Como FastAPI, Elysia genera docs desde tus esquemas:
bun add @elysiajs/swaggerimport { swagger } from '@elysiajs/swagger'
new Elysia()
.use(swagger()) // docs en /swagger
.use(productos)
.listen(3000)7.8 · Autenticación (JWT) y ciclo de vida
Elysia tiene un plugin JWT y hooks de ciclo de vida (onBeforeHandle, derive, etc.):
bun add @elysiajs/jwtimport { jwt } from '@elysiajs/jwt'
const app = new Elysia()
.use(jwt({ name: 'jwt', secret: process.env.JWT_SECRET! }))
// Login: firma un token
.post('/login', async ({ jwt, body }) => {
// ...verificar credenciales...
return { token: await jwt.sign({ sub: body.usuario }) }
}, { body: t.Object({ usuario: t.String(), password: t.String() }) })
// Rutas protegidas: guard con derive + onBeforeHandle
.guard({
async beforeHandle({ jwt, headers, status }) {
const token = headers.authorization?.split(' ')[1]
const payload = token && await jwt.verify(token)
if (!payload) return status(401, { error: 'No autorizado' })
}
}, (app) => app
.get('/perfil', () => ({ ok: true }))
)
.listen(3000)💡 Hooks de ciclo de vida:
deriveañade datos a cada petición (como el usuario actual);onBeforeHandlevalida antes del handler (como un Guard de Nest);onAfterHandletransforma la respuesta (como un Interceptor). Todo tipado y encadenado.
7.9 · Tests (con el runner nativo de Bun)
Bun trae un test runner integrado (compatible con la API de Jest) — no instalas nada:
// tests/productos.test.ts
import { describe, it, expect } from 'bun:test'
import { productos } from '../src/productos'
describe('Productos API', () => {
it('crea un producto', async () => {
const res = await productos.handle(
new Request('http://localhost/productos/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: 'Teclado', precio: 99.9 }),
})
)
expect(res.status).toBe(201)
const json = await res.json()
expect(json.nombre).toBe('Teclado')
})
it('rechaza precio negativo → 422', async () => {
const res = await productos.handle(
new Request('http://localhost/productos/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: 'X', precio: -1 }),
})
)
expect(res.status).toBe(422)
})
})bun test # ejecuta todos los tests, rapidísimo
bun test --coverage🧠
app.handle(request): Elysia te deja pasar unaRequestestándar del navegador directamente al handler y obtener laResponse. No necesitas levantar un servidor real para testear. Elegante y veloz.
7.10 · Eden — cliente type-safe (bonus)
El superpoder final de Elysia: Eden genera un cliente para tu frontend con los tipos de tu backend. Si borras un endpoint o cambias un campo, tu frontend deja de compilar. Contrato garantizado sin generar código:
import { treaty } from '@elysiajs/eden'
import type { App } from '../backend/src/index' // ¡importa el TIPO del backend!
const api = treaty<App>('localhost:3000')
const { data } = await api.productos.post({ nombre: 'Mouse', precio: 49.9 })
// ▲ autocompletado y tipado desde el backendEsto no existe en ningún otro stack del libro. Es lo que hace especial al mundo Bun/Elysia.
7.10.1 · Manejo de errores global (onError) y rate limiting
Sin un manejador global, un error inesperado (una excepción no controlada, una promesa rechazada) deja escapar un 500 con la traza cruda de Bun — mala idea en producción. El hook onError centraliza la respuesta, igual que un Exception Filter en Nest (6.9.1) o @app.errorhandler en Flask (4.8):
import { Elysia } from 'elysia'
const app = new Elysia()
.onError(({ code, error, status }) => {
if (code === 'VALIDATION') return status(422, { error: error.message })
if (code === 'NOT_FOUND') return status(404, { error: 'Recurso no encontrado' })
console.error(error) // loguea el resto (apéndice F)
return status(500, { error: 'Error interno del servidor' })
})
.use(productos)
.listen(3000)Para limitar peticiones (fuerza bruta en login, abuso de un endpoint caro), Elysia no trae rate limiting de fábrica — se añade con un plugin comunitario o unas pocas líneas contra Redis (apéndice B, patrón token bucket):
bun add elysia-rate-limitimport { rateLimit } from 'elysia-rate-limit'
new Elysia()
.use(rateLimit({ duration: 60_000, max: 10 })) // 10 peticiones por minuto por IP
.use(productos)⚠️
onErrorno sustituye a validar bien tus esquemast.*(7.4) — es la última línea de defensa para lo que se te escapó, no la primera. Registra siempre el error real (console.erroro tu logger estructurado, apéndice F) antes de devolver el mensaje genérico: si no, un bug en producción se vuelve invisible.
7.11 · Buenas prácticas Elysia/Bun
- Un
new Elysia()por feature, compón con.use(). - Valida TODO con esquemas (
t.*): entrada, salida, params, query. Es gratis y te da tipos. - Aprovecha el type-safety: deja que TypeScript te guíe; si algo no compila, hay un bug real.
- Usa Drizzle para BD tipada, o
bun:sqlitepara algo simple/local. - Tests con
bun:testusandoapp.handle()(sin levantar servidor). bunen Docker: imagen baseoven/bun:1(cap. 12), arranca en milisegundos.- Variables de entorno: Bun carga
.envautomáticamente, sin librerías.
✅ Ejercicio del capítulo
La API del blog en Elysia, exprimiendo el type-safety:
1. Módulos Elysia: articulos, comentarios, auth, compuestos con .use().
2. Drizzle: esquema con relación articulo↔comentario + migraciones.
3. Esquemas t.* para body, params y response en cada endpoint.
4. JWT con plugin + guard beforeHandle en la escritura.
5. Swagger en /swagger.
6. Tests con bun:test y app.handle() (mínimo 5).
7. BONUS: crea un mini cliente con Eden y comprueba el autocompletado.
8. Añade un `onError` global que traduzca errores de validación y "no encontrado"
a respuestas consistentes, y un rate limit de 5/minuto al login.Has visto el stack más moderno. Ahora bajamos al nivel más "ingenieril": un lenguaje compilado, Go.
💡 Pistas de la solución
.use()compone instancias de Elysia: creanew Elysia({ prefix: '/articulos' })en su propio archivo, exporta la instancia, y móntala en la raíz conapp.use(articulosModule).- Los esquemas
t.*no son solo para el body: pon tambiénparams: t.Object({ id: t.Numeric() })para que elidde la URL llegue ya convertido a número, no como string. - Para el bonus de Eden: el tipo
Appque importa el frontend debe ser el tipo exportado de la instancia raíz de Elysia (export type App = typeof app), no una clase ni una interfaz escrita a mano — el truco de Eden es que ese tipo SE INFIERE de tus rutas reales.
🧠 Autoevaluación
Dos cosas a la vez: rechaza automáticamente peticiones inválidas (como Pydantic en FastAPI, cap. 05) Y genera el tipo de TypeScript del dato validado — así el resto de tu código sabe con certeza qué forma tiene sin volver a comprobarlo.
Swagger describe la API para que un humano la lea; si el backend cambia y nadie actualiza la documentación, queda desactualizada sin que nada te avise. Eden importa el TIPO real del backend: si borras un endpoint o cambias un campo, el frontend deja literalmente de compilar — es imposible que el contrato se desincronice en silencio.
NestJS impone estructura con decoradores, módulos e inyección de dependencias — el framework te obliga a organizar. Elysia confía en el sistema de tipos de TypeScript y una API mínima (.use(), esquemas t.*): la corrección viene de que el código no compila si algo no encaja, no de reglas del framework.
Porque el cliente solo debe ver "Error interno del servidor" (nunca la traza ni detalles internos, por seguridad), pero si nadie registra el error real en algún sitio (consola, o un sistema de logs del apéndice F), el fallo se vuelve invisible para el equipo — nadie se entera de que algo se rompió hasta que un usuario se queja.
Siguiente: 08-go.md — rendimiento, concurrencia y el estándar de la nube.