ElysiaJS + Bun : Construire une API REST avec un typage de bout en bout

ElysiaJS + Bun : Construire une API REST avec un typage de bout en bout
Si vous construisez des API avec Express ou Fastify, il est temps de découvrir ElysiaJS — un framework web natif Bun qui offre un typage de bout en bout, une documentation OpenAPI automatique et des performances rivalisant avec les frameworks Go et Rust.
Là où Hono se concentre sur la portabilité multi-runtime, ElysiaJS mise entièrement sur les performances de Bun et le système de types de TypeScript. Le résultat ? Un framework API où vos gestionnaires de routes, vos schémas de validation et même votre client frontend partagent une source unique de vérité pour les types — sans aucune génération de code.
Pourquoi ElysiaJS ?
Voici ce qui distingue ElysiaJS dans le paysage des API TypeScript :
- Typage de bout en bout : les types circulent de la définition de la route à la validation jusqu'au client — pas de
any, pas de cast - Performances natives Bun : construit spécifiquement pour Bun, exploitant son serveur HTTP interne rapide
- OpenAPI/Swagger automatique : chaque route validée génère automatiquement la documentation OpenAPI 3.0
- Eden Treaty : un client typé qui infère les types directement depuis votre serveur — aucune génération de code nécessaire
- Écosystème de plugins : authentification Bearer, CORS, JWT, GraphQL et plus en plugins officiels
- Validation déclarative : validation de schémas basée sur TypeBox qui sert aussi de types TypeScript
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Bun 1.1+ installé (bun.sh)
- Connaissances de base en TypeScript
- Familiarité avec les concepts REST API
- Un éditeur de code (VS Code recommandé)
Ce tutoriel utilise ElysiaJS 1.2+ et Bun 1.1+. Si vous venez d'Express ou Hono, vous retrouverez de nombreux patterns familiers avec des ajouts puissants.
Ce que vous allez construire
Nous allons construire une API de gestion de tâches complète avec :
- Opérations CRUD complètes pour les tâches
- Validation des entrées avec des schémas typés
- Authentification par token Bearer
- Regroupement de routes et guards
- Documentation Swagger automatique
- Un client typé utilisant Eden Treaty
Étape 1 : Configuration du projet
Créez un nouveau projet ElysiaJS avec le scaffolding intégré de Bun :
bun create elysia task-api
cd task-apiCela crée une structure de projet minimale. Installons les plugins nécessaires :
bun add @elysiajs/swagger @elysiajs/bearer @elysiajs/corsLa structure de votre projet devrait ressembler à ceci :
task-api/
├── src/
│ └── index.ts
├── package.json
├── tsconfig.json
└── bun.lock
Organisons-le pour une API en conditions réelles :
mkdir -p src/{routes,models,plugins,middleware}Structure mise à jour :
task-api/
├── src/
│ ├── routes/
│ │ └── tasks.ts
│ ├── models/
│ │ └── task.model.ts
│ ├── plugins/
│ │ └── auth.ts
│ ├── middleware/
│ └── index.ts
├── package.json
└── tsconfig.json
Étape 2 : Définir vos modèles de données
ElysiaJS utilise TypeBox (t) pour les schémas de validation. La beauté de ce système, c'est que ces schémas sont simultanément :
- Des validateurs à l'exécution (comme Zod)
- Des types TypeScript (par inférence)
- Des définitions de schémas OpenAPI
Créez src/models/task.model.ts :
import { Elysia, t } from 'elysia'
// Définir les schémas de tâches comme plugin Elysia pour la réutilisation
export const taskModel = new Elysia({ name: 'Model.Task' })
.model({
// Schéma de création de tâche
'task.create': t.Object({
title: t.String({ minLength: 1, maxLength: 200 }),
description: t.Optional(t.String({ maxLength: 1000 })),
priority: t.Optional(
t.Union([
t.Literal('low'),
t.Literal('medium'),
t.Literal('high')
], { default: 'medium' })
),
dueDate: t.Optional(t.String({ format: 'date' }))
}),
// Schéma de mise à jour de tâche
'task.update': t.Object({
title: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
description: t.Optional(t.String({ maxLength: 1000 })),
priority: t.Optional(
t.Union([
t.Literal('low'),
t.Literal('medium'),
t.Literal('high')
])
),
completed: t.Optional(t.Boolean()),
dueDate: t.Optional(t.String({ format: 'date' }))
}),
// Schéma de réponse de tâche
'task.response': t.Object({
id: t.String(),
title: t.String(),
description: t.Nullable(t.String()),
priority: t.Union([
t.Literal('low'),
t.Literal('medium'),
t.Literal('high')
]),
completed: t.Boolean(),
dueDate: t.Nullable(t.String()),
createdAt: t.String(),
updatedAt: t.String()
}),
// Paramètres communs
'task.params': t.Object({
id: t.String()
})
})En encapsulant les modèles dans un plugin Elysia, vous obtenez l'auto-complétion lors du référencement des noms de modèles dans les gestionnaires de routes. Pas de fautes de frappe — le compilateur les détecte.
Étape 3 : Construire le stockage en mémoire
Pour ce tutoriel, nous utiliserons un stockage simple en mémoire. En production, vous le remplacerez par une base de données (SQLite via Bun, Drizzle ORM, Prisma, etc.).
Créez src/models/store.ts :
export interface Task {
id: string
title: string
description: string | null
priority: 'low' | 'medium' | 'high'
completed: boolean
dueDate: string | null
createdAt: string
updatedAt: string
}
// Stockage simple en mémoire
class TaskStore {
private tasks: Map<string, Task> = new Map()
create(data: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Task {
const id = crypto.randomUUID()
const now = new Date().toISOString()
const task: Task = {
id,
...data,
createdAt: now,
updatedAt: now
}
this.tasks.set(id, task)
return task
}
getAll(): Task[] {
return Array.from(this.tasks.values())
}
getById(id: string): Task | undefined {
return this.tasks.get(id)
}
update(id: string, data: Partial<Omit<Task, 'id' | 'createdAt'>>): Task | undefined {
const task = this.tasks.get(id)
if (!task) return undefined
const updated: Task = {
...task,
...data,
updatedAt: new Date().toISOString()
}
this.tasks.set(id, updated)
return updated
}
delete(id: string): boolean {
return this.tasks.delete(id)
}
filter(predicate: (task: Task) => boolean): Task[] {
return this.getAll().filter(predicate)
}
}
export const taskStore = new TaskStore()Étape 4 : Créer les routes des tâches
Maintenant, passons au cœur de notre API. Créez src/routes/tasks.ts :
import { Elysia, t } from 'elysia'
import { taskModel } from '../models/task.model'
import { taskStore } from '../models/store'
export const taskRoutes = new Elysia({ prefix: '/tasks' })
.use(taskModel)
// GET /tasks - Lister toutes les tâches avec filtrage optionnel
.get('/', ({ query }) => {
let tasks = taskStore.getAll()
if (query.completed !== undefined) {
tasks = tasks.filter(t => t.completed === (query.completed === 'true'))
}
if (query.priority) {
tasks = tasks.filter(t => t.priority === query.priority)
}
return {
data: tasks,
total: tasks.length
}
}, {
query: t.Object({
completed: t.Optional(t.String()),
priority: t.Optional(
t.Union([
t.Literal('low'),
t.Literal('medium'),
t.Literal('high')
])
)
}),
detail: {
tags: ['Tasks'],
summary: 'Lister toutes les tâches'
}
})
// GET /tasks/:id - Obtenir une tâche spécifique
.get('/:id', ({ params, status }) => {
const task = taskStore.getById(params.id)
if (!task) {
return status(404, {
error: 'Tâche introuvable',
id: params.id
})
}
return task
}, {
params: 'task.params',
detail: {
tags: ['Tasks'],
summary: 'Obtenir une tâche par ID'
}
})
// POST /tasks - Créer une nouvelle tâche
.post('/', ({ body }) => {
const task = taskStore.create({
title: body.title,
description: body.description ?? null,
priority: body.priority ?? 'medium',
completed: false,
dueDate: body.dueDate ?? null
})
return task
}, {
body: 'task.create',
response: 'task.response',
detail: {
tags: ['Tasks'],
summary: 'Créer une nouvelle tâche'
}
})
// PATCH /tasks/:id - Mettre à jour une tâche
.patch('/:id', ({ params, body, status }) => {
const task = taskStore.update(params.id, {
...(body.title !== undefined && { title: body.title }),
...(body.description !== undefined && { description: body.description }),
...(body.priority !== undefined && { priority: body.priority }),
...(body.completed !== undefined && { completed: body.completed }),
...(body.dueDate !== undefined && { dueDate: body.dueDate })
})
if (!task) {
return status(404, {
error: 'Tâche introuvable',
id: params.id
})
}
return task
}, {
params: 'task.params',
body: 'task.update',
detail: {
tags: ['Tasks'],
summary: 'Mettre à jour une tâche'
}
})
// DELETE /tasks/:id - Supprimer une tâche
.delete('/:id', ({ params, status }) => {
const deleted = taskStore.delete(params.id)
if (!deleted) {
return status(404, {
error: 'Tâche introuvable',
id: params.id
})
}
return { success: true, id: params.id }
}, {
params: 'task.params',
detail: {
tags: ['Tasks'],
summary: 'Supprimer une tâche'
}
})Remarquez comment chaque gestionnaire de route utilise les noms de modèles ('task.create', 'task.params', etc.) plutôt que des schémas en ligne. Cela garde les routes propres et assure la cohérence.
Étape 5 : Ajouter l'authentification avec les guards
Les guards ElysiaJS vous permettent de protéger des groupes de routes avec une validation partagée et des hooks. Créez src/plugins/auth.ts :
import { Elysia } from 'elysia'
import { bearer } from '@elysiajs/bearer'
// En production, utilisez une vraie bibliothèque JWT et une base de données
const VALID_TOKENS = new Set([
'demo-token-2026'
])
export const authPlugin = new Elysia({ name: 'Plugin.Auth' })
.use(bearer())
.derive(({ bearer }) => {
return {
isAuthenticated: bearer ? VALID_TOKENS.has(bearer) : false
}
})
.macro({
requireAuth(enabled: boolean) {
if (!enabled) return
return {
beforeHandle({ isAuthenticated, status, set }) {
if (!isAuthenticated) {
set.headers['WWW-Authenticate'] = 'Bearer realm="task-api"'
return status(401, {
error: 'Non autorisé',
message: 'Un token Bearer valide est requis'
})
}
}
}
}
})Le token codé en dur ci-dessus est uniquement à des fins de démonstration. En production, utilisez des tokens JWT avec une signature appropriée, une expiration et une vraie base de données utilisateurs.
Maintenant, mettez à jour src/routes/tasks.ts pour utiliser le plugin d'authentification pour les opérations d'écriture :
import { Elysia, t } from 'elysia'
import { taskModel } from '../models/task.model'
import { taskStore } from '../models/store'
import { authPlugin } from '../plugins/auth'
export const taskRoutes = new Elysia({ prefix: '/tasks' })
.use(taskModel)
.use(authPlugin)
// Les routes GET restent publiques (pas de requireAuth)
.get('/', ({ query }) => {
// ... même code qu'avant
}, {
// ... même configuration
})
.get('/:id', ({ params, status }) => {
// ... même code qu'avant
}, {
// ... même configuration
})
// POST, PATCH, DELETE nécessitent maintenant l'authentification
.post('/', ({ body }) => {
// ... même gestionnaire
}, {
body: 'task.create',
response: 'task.response',
requireAuth: true, // Protégé !
detail: {
tags: ['Tasks'],
summary: 'Créer une nouvelle tâche',
security: [{ bearer: [] }]
}
})
.patch('/:id', ({ params, body, status }) => {
// ... même gestionnaire
}, {
params: 'task.params',
body: 'task.update',
requireAuth: true, // Protégé !
detail: {
tags: ['Tasks'],
summary: 'Mettre à jour une tâche',
security: [{ bearer: [] }]
}
})
.delete('/:id', ({ params, status }) => {
// ... même gestionnaire
}, {
params: 'task.params',
requireAuth: true, // Protégé !
detail: {
tags: ['Tasks'],
summary: 'Supprimer une tâche',
security: [{ bearer: [] }]
}
})Le macro requireAuth: true sépare proprement la logique d'authentification de la logique métier. Pas de chaînes de middleware, pas de next() — juste de la configuration déclarative.
Étape 6 : Assembler le tout
Mettez à jour src/index.ts pour composer toutes les pièces :
import { Elysia } from 'elysia'
import { swagger } from '@elysiajs/swagger'
import { cors } from '@elysiajs/cors'
import { taskRoutes } from './routes/tasks'
const app = new Elysia()
// Plugins globaux
.use(cors())
.use(swagger({
documentation: {
info: {
title: 'API de gestion de tâches',
version: '1.0.0',
description: 'Une API REST typée construite avec ElysiaJS et Bun'
},
tags: [
{ name: 'Tasks', description: 'Opérations CRUD sur les tâches' },
{ name: 'Health', description: "Vérifications de santé de l'API" }
],
components: {
securitySchemes: {
bearer: {
type: 'http',
scheme: 'bearer'
}
}
}
}
}))
// Vérification de santé
.get('/health', () => ({
status: 'ok',
timestamp: new Date().toISOString(),
runtime: 'bun',
version: '1.0.0'
}), {
detail: {
tags: ['Health'],
summary: "Vérification de santé de l'API"
}
})
// Monter les groupes de routes
.use(taskRoutes)
// Gestionnaire d'erreurs global
.onError(({ code, error }) => {
if (code === 'VALIDATION') {
return {
error: 'Erreur de validation',
message: error.message
}
}
return {
error: 'Erreur interne du serveur',
message: "Quelque chose s'est mal passé"
}
})
.listen(3000)
console.log(`🦊 API de tâches en cours sur ${app.server?.hostname}:${app.server?.port}`)
console.log(`📚 Documentation Swagger sur http://localhost:3000/swagger`)
// Exporter le type de l'app pour Eden Treaty
export type App = typeof appLa dernière ligne — export type App = typeof app — est l'ingrédient magique. ElysiaJS infère le type complet de l'API à partir de vos définitions de routes, et Eden Treaty utilise ce type côté client.
Étape 7 : Tester l'API
Démarrez le serveur :
bun run src/index.tsMaintenant, testez chaque endpoint :
# Vérification de santé
curl http://localhost:3000/health
# Créer une tâche (avec authentification)
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer demo-token-2026" \
-d '{"title": "Apprendre ElysiaJS", "priority": "high"}'
# Lister toutes les tâches (public)
curl http://localhost:3000/tasks
# Filtrer par priorité
curl "http://localhost:3000/tasks?priority=high"
# Mettre à jour une tâche
curl -X PATCH http://localhost:3000/tasks/<task-id> \
-H "Content-Type: application/json" \
-H "Authorization: Bearer demo-token-2026" \
-d '{"completed": true}'
# Supprimer une tâche
curl -X DELETE http://localhost:3000/tasks/<task-id> \
-H "Authorization: Bearer demo-token-2026"
# Essayer sans authentification (devrait retourner 401)
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Devrait échouer"}'Ouvrez http://localhost:3000/swagger dans votre navigateur pour voir la documentation interactive générée automatiquement.
Étape 8 : Ajouter le client Eden Treaty
C'est là qu'ElysiaJS brille vraiment. Eden Treaty vous donne un client HTTP entièrement typé qui infère les types directement depuis votre code serveur — pas de génération de code, pas de fichiers de schéma séparés.
Installez Eden :
bun add @elysiajs/edenCréez src/client.ts :
import { treaty } from '@elysiajs/eden'
import type { App } from './index'
// Créer un client typé
const api = treaty<App>('localhost:3000')
async function demo() {
// ✅ Auto-complétion complète pour le chemin, le corps et la réponse
const { data: tasks } = await api.tasks.get({
query: { priority: 'high' }
})
console.log('Tâches haute priorité :', tasks)
// ✅ TypeScript connaît la forme du corps
const { data: newTask } = await api.tasks.post({
title: 'Livrer la fonctionnalité',
priority: 'high'
}, {
headers: {
authorization: 'Bearer demo-token-2026'
}
})
console.log('Tâche créée :', newTask)
// ✅ TypeScript sait que cela retourne Task | error
if (newTask) {
const { data: updated } = await api.tasks({ id: newTask.id }).patch({
completed: true
}, {
headers: {
authorization: 'Bearer demo-token-2026'
}
})
console.log('Tâche mise à jour :', updated)
}
}
demo()L'idée clé : si vous changez le type de réponse d'une route côté serveur, TypeScript signale immédiatement chaque appel client qui dépend de l'ancien type. Pas de surprises à l'exécution, pas besoin de test d'intégration pour détecter le décalage.
Étape 9 : Hooks de cycle de vie et logique personnalisée
ElysiaJS fournit un système de cycle de vie riche pour les préoccupations transversales. Voici des exemples pratiques :
Journalisation des requêtes
import { Elysia } from 'elysia'
export const loggerPlugin = new Elysia({ name: 'Plugin.Logger' })
.onRequest(({ request }) => {
console.log(`→ ${request.method} ${new URL(request.url).pathname}`)
})
.onAfterResponse(({ request, set }) => {
console.log(`← ${request.method} ${new URL(request.url).pathname} ${set.status ?? 200}`)
})Limitation de débit
import { Elysia } from 'elysia'
const requestCounts = new Map<string, { count: number; resetAt: number }>()
export const rateLimitPlugin = new Elysia({ name: 'Plugin.RateLimit' })
.derive(({ request }) => {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
return { clientIp: ip }
})
.onBeforeHandle(({ clientIp, status, set }) => {
const now = Date.now()
const window = 60_000 // 1 minute
const limit = 100
let entry = requestCounts.get(clientIp)
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + window }
requestCounts.set(clientIp, entry)
}
entry.count++
set.headers['X-RateLimit-Limit'] = String(limit)
set.headers['X-RateLimit-Remaining'] = String(Math.max(0, limit - entry.count))
if (entry.count > limit) {
set.headers['Retry-After'] = String(Math.ceil((entry.resetAt - now) / 1000))
return status(429, { error: 'Trop de requêtes' })
}
})Étape 10 : Tests avec le testeur intégré de Bun
Bun inclut un testeur — pas de dépendances supplémentaires nécessaires. Créez src/__tests__/tasks.test.ts :
import { describe, it, expect, beforeAll } from 'bun:test'
import { treaty } from '@elysiajs/eden'
import { Elysia } from 'elysia'
import { taskRoutes } from '../routes/tasks'
// Créer une instance de test
const app = new Elysia()
.use(taskRoutes)
const api = treaty(app)
describe('API de tâches', () => {
let taskId: string
it('devrait créer une tâche', async () => {
const { data, status } = await api.tasks.post({
title: 'Tâche de test',
priority: 'high'
}, {
headers: {
authorization: 'Bearer demo-token-2026'
}
})
expect(status).toBe(200)
expect(data?.title).toBe('Tâche de test')
expect(data?.priority).toBe('high')
expect(data?.completed).toBe(false)
taskId = data!.id
})
it('devrait lister les tâches', async () => {
const { data } = await api.tasks.get()
expect(data?.total).toBeGreaterThan(0)
})
it('devrait supprimer une tâche', async () => {
const { data } = await api.tasks({ id: taskId }).delete(undefined, {
headers: {
authorization: 'Bearer demo-token-2026'
}
})
expect(data?.success).toBe(true)
})
})Lancez les tests :
bun testRemarquez comment nous avons utilisé treaty(app) directement avec l'instance Elysia au lieu de faire des requêtes HTTP. ElysiaJS gère cela en interne, rendant les tests rapides et simples — pas besoin de démarrer le serveur.
ElysiaJS vs Hono vs Express : quand choisir quoi
| Fonctionnalité | ElysiaJS | Hono | Express |
|---|---|---|---|
| Runtime | Bun en priorité | Multi-runtime | Node.js |
| Typage | De bout en bout (Eden) | Niveau route | Manuel |
| Validation | Intégrée (TypeBox) | Adaptateur Zod | Manuel/Joi |
| OpenAPI | Généré automatiquement | Manuel/plugin | swagger-jsdoc |
| Performance | Très rapide | Rapide | Modérée |
| Écosystème | En croissance | En croissance | Massif |
| Courbe d'apprentissage | Modérée | Faible | Faible |
Choisissez ElysiaJS quand : vous voulez un typage maximal, utilisez déjà Bun et valorisez la documentation API automatique.
Choisissez Hono quand : vous avez besoin du support multi-runtime (Cloudflare Workers, Deno) ou préférez un framework plus léger.
Choisissez Express quand : vous avez besoin du plus grand écosystème et acceptez la configuration manuelle des types.
Dépannage
Problèmes courants et solutions :
"Cannot find module '@elysiajs/swagger'"
Assurez-vous d'avoir installé avec bun add, pas npm install. Les plugins ElysiaJS sont optimisés pour Bun.
Erreurs de types avec les références de modèles
Assurez-vous que .use(taskModel) est appelé avant de référencer les noms de modèles. ElysiaJS résout les types par ordre de composition des plugins.
Erreurs "VALIDATION" sur des entrées valides
Vérifiez que votre en-tête Content-Type: application/json est défini. ElysiaJS valide strictement les types de contenu des requêtes.
Les types Eden Treaty affichent any
Assurez-vous d'exporter type App = typeof app depuis votre fichier serveur, et de l'importer comme import de type uniquement : import type { App }.
Prochaines étapes
Maintenant que vous avez une base solide, voici comment étendre ce projet :
- Ajouter une base de données : remplacez le stockage en mémoire par SQLite (intégré à Bun) ou utilisez Drizzle ORM
- Authentification JWT : remplacez les tokens Bearer par une signature JWT appropriée avec
@elysiajs/jwt - Support WebSocket : ElysiaJS a un support WebSocket de première classe — ajoutez des mises à jour de tâches en temps réel
- Upload de fichiers : gérez les formulaires multipart avec le support intégré
- Déploiement en production : ElysiaJS fonctionne parfaitement sur Fly.io, Railway ou tout hôte Docker exécutant Bun
Conclusion
ElysiaJS représente une nouvelle approche pour construire des API TypeScript. Au lieu de greffer le typage sur un framework existant, il a été conçu de zéro pour que les types circulent naturellement des définitions de routes à travers la validation jusqu'au client.
La combinaison de la vitesse brute de Bun, de la validation TypeBox, de la génération automatique OpenAPI et du client typé Eden Treaty crée une expérience de développement où le compilateur détecte les bugs d'intégration avant qu'ils n'atteignent la production. C'est le véritable pouvoir du typage de bout en bout — pas simplement vérifier les types de fonctions individuelles, mais garantir que l'ensemble de la chaîne requête/réponse est correct au moment de la compilation.
Si vous démarrez un nouveau projet Bun et que vous valorisez le typage, ElysiaJS mérite une attention sérieuse.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Créer des API REST avec Hono et Bun : L'alternative moderne à Express
Apprenez à construire des API REST rapides et typées avec le framework Hono et le runtime Bun. Guide complet de l'installation au déploiement avec des exemples pratiques.

AI SDK 4.0 : Nouvelles Fonctionnalites et Cas d'Utilisation
Decouvrez les nouvelles fonctionnalites et cas d'utilisation d'AI SDK 4.0, incluant le support PDF, l'utilisation de l'ordinateur et plus encore.

Créer un Web Scraper Intelligent avec Playwright et l'API Claude en TypeScript
Apprenez à construire un scraper web intelligent qui utilise Playwright pour l'automatisation du navigateur et l'IA Claude pour extraire, nettoyer et structurer les données de n'importe quel site — sans sélecteurs CSS fragiles.