Migrating Express → Bun/Elysia
Porting an existing Efesto Express project to the Bun/Elysia stack
is mostly mechanical: the class shape, the per-method convention (_<verb>,
_<verb>Swagger, _<verb>Multer), the Swagger DSL and the filesystem routing all carry
over unchanged. What genuinely differs — handler signatures, validation, the app
bootstrap — is a small, concentrated residue.
Efesto ships a codemod, efesto-migrate, that does the mechanical part for you and
flags the rest. The design principle is what makes it safe to trust.
The safety principle
The codemod trades on an asymmetry, not a percentage: it may emit code that does
not run (a compile error, or a // EFESTO-TODO marker), but it will never emit
code that runs and means something different from the original. Only transformations that
are provably equivalent are applied automatically; everything else is left untouched and
marked for you to review.
So you never have to diff the output line-by-line wondering if a behaviour silently changed — if it compiles and there are no markers, the mechanical translation is faithful.
Running it
Work on a clean git tree so you can review the diff. The codemod edits files in place.
# dry-run: report only, writes nothing
bunx efesto-migrate ./src/routes --dry
# apply the transformations in place
bunx efesto-migrate ./src/routes
From inside the Efesto repo you can run it without publishing:
bun packages/migrate/index.ts ./src/routes --dry
The report lists, per file, the automatic transformations applied and every point that
needs human confirmation, each searchable in the code as a // EFESTO-TODO comment:
efesto-migrate — 5 files analysed
✔ 4 files with automatic transformations applied (import, handler, return)
⚠ 3 points need human confirmation (search for // EFESTO-TODO):
src/routes/user/index.ts:12 [validation] _putValidation: convert the express-validator chain to TypeBox
src/routes/user/index.ts:24 [swaggerModel] `swaggerModel` is ignored on the Elysia stack: move schemas to efesto({ models })
src/routes/user/[id].ts:31 [cache] cache.key: identifiers out of Elysia scope [allParams]
What it does automatically (AUTO)
These are applied for you with no loss of meaning:
- Import —
from "efesto"→from "efesto/elysia". - Handler signature —
(req, res, next)→ destructuring of only the fields used, e.g.({ body, params }). Applied only whenreqis used exclusively asreq.{body,params,query,headers},resonly insidereturn res.json(x), andnextis never used. - Field access —
req.<field>→<field>, andreturn res.json(x)→return x. - Generated types — references to generated types (
*Types.*) are stripped from the handler signature (the nativeContext/ TypeBox replace them).
Anything outside this whitelist is not rewritten — it is marked instead.
What it flags for you (// EFESTO-TODO)
These need a human because there is no faithful 1:1 translation:
| Marker | Why it can't be automatic |
|---|---|
| Non-standard handler | res.status/res.send/res.redirect, next, req.file, or any non-trivial req access — control flow has to be rethought into (ctx) => return value. |
| express-validator → TypeBox | A _<verb>Validation written as an array (express-validator chain) must become a TypeBox { body, query, params } schema. Highest risk of semantic drift (coercion, optionals, custom validators). |
swaggerModel | The service-level swaggerModel is ignored on the Elysia stack — lift its schemas into the central efesto({ models }) registry. |
| Non-portable cache keys | key/purgeKey templates referencing identifiers outside the Elysia scope ({ctx, params, query, body, headers, store}) — e.g. req.user, helper variables like allParams — won't compile and must be rewritten. |
| express imports | import from express / express-validator to remove. |
A subtle one the codemod catches: a handler that does ...req.body and has multer on
that verb is not a mechanical rename. On Elysia the merged body also contains the
uploaded files, so spreading it would leak them — this is flagged for manual conversion.
The mapping reference
If you are migrating by hand, or want to understand a marker, here is the full Express → Elysia mapping. Each row is classified:
- MECHANICAL — 1:1 translation, handled by the codemod.
- JUDGMENT — needs semantic understanding; this is the real residue.
- REWRITE — no 1:1 mapping; rewritten once per project (bootstrap).
Service definition
The recommended target for an Express BaseApiService is the Elysia class form (not
the native Elysia instance) — it preserves the _<verb> convention and minimises the diff.
| Concept | Express | Elysia | Type |
|---|---|---|---|
| Import | from "efesto" | from "efesto/elysia" | MECHANICAL |
| Base class | extends BaseApiService | extends BaseApiService | IDENTICAL |
| Constructor | super(__filename) | optional, ignored (path derived from the file) | MECHANICAL |
| Verb convention | _get, _getSwagger, _getValidation, _getMulter | identical | IDENTICAL |
| Swagger DSL | _<verb>Swagger: SwaggerOptions (@Model, $ref, isPopulation…) | identical (same expandSwagger) | IDENTICAL |
swaggerModel | on the service | ignored → lift into efesto({ models }) | REWRITE |
Handler
| Express | Elysia | Type |
|---|---|---|
async _get(req, res, next) | _get(ctx: Context) | JUDGMENT |
req.body / req.params / req.query | destructured from Context: ({ body, params, query }) => | MECHANICAL |
return res.json(x) | return x | MECHANICAL |
res.status(n).json(x) | ({ set }) => { set.status = n; return x } | JUDGMENT |
res.send / res.redirect / manual headers | Elysia API (set.headers, redirect, raw return) | JUDGMENT |
next(err) / throw | throw (handled by onError) | MECHANICAL |
Generated types ModelXTypes.* | native Context (+ TypeBox) | JUDGMENT |
Validation
| Express | Elysia | Type |
|---|---|---|
_putValidation = [check(["name"]).exists()] (express-validator) | _putValidation: RouteValidation = { body: t.Object({ name: t.String() }) } (TypeBox) | JUDGMENT |
There is no 1:1: express-validator chains become TypeBox schemas
({ body, query, params, response }). This is the highest-risk spot for semantic drift.
See Validation.
Uploads / Multer
| Express | Elysia | Type |
|---|---|---|
_putMulter = true / _putMultipleMulter = true | identical; without a _<verb>Validation.body, Efesto auto-injects t.Object({ file: t.File() }) / t.Object({ files: t.Files() }) | IDENTICAL |
req.file / req.files | body.file / body.files (web File) | MECHANICAL |
| (precedence) | declaring _<verb>Validation.body overrides the multer auto-body — re-declare files there yourself | JUDGMENT |
See File Uploads.
Cache / purge
| Express | Elysia | Type |
|---|---|---|
config.redis: { host, port, defaultExpiresInSeconds } | efesto({ redis: {...} }) or redisClient | REWRITE |
Static cache/purgeKey keys (e.g. `users`, `single-id-*`) | identical (compiled once at mount) | MECHANICAL |
Dynamic keys req.params.id / req.query.x / req.body.x | params.id / query.x / body.x (scope: ctx, params, query, body, headers, store) | MECHANICAL |
Keys using other req fields (req.user, req.tenantId…) | not in scope → rewrite via store/ctx or rethink the key | JUDGMENT |
See Caching.
Permissions / ABAC
| Express | Elysia | Type |
|---|---|---|
abacPermissions in config + req.ability via middleware | abac in efesto({ abac }) + ability via setup: app => app.derive(() => ({ ability })) | REWRITE |
abacPermissions.reqAbilityField | abac.abilityField (default "ability") | MECHANICAL |
abacPermissions.checkPermissionBeforeResolver | abac.checkPermissionBeforeResolver (default true) | MECHANICAL |
permission shape { action, model } | permission: [action, model] | JUDGMENT (different shape) |
See ABAC Permissions.
_id → id, routing, runtime
| Express | Elysia | Type |
|---|---|---|
Efesto.mongoIdParser global | efesto({ mongoIdParser: true }) (off by default) | MECHANICAL |
routes/user/[id].ts → /user/:id | identical | IDENTICAL |
__dirname / __filename | import.meta.dir | MECHANICAL |
Node + tsc build + node dist/... | Bun, no build (bun run lib/server.ts) | REWRITE |
App bootstrap (one-time rewrite)
The server entrypoint has no 1:1 mapping — rewrite it once, using
example/elysia-project/lib/server.ts as the reference template.
Express (app.ts) | Elysia (server.ts) |
|---|---|
express() + bodyParser/cors/compression | efesto({...}).listen(port) (cors etc. via setup or Elysia plugins) |
authMiddleware / errorMiddleware | setup: app => app.onBeforeHandle(...).onError(...) |
req.ability = defineAbility(...) middleware | setup: app => app.derive(() => ({ ability })) |
swagger via swagger-ui-express | native @elysiajs/openapi (served at /<prefix>/openapi) |
After the codemod
The codemod handles the MECHANICAL part. To get to a correct runtime, do these once:
- Resolve the
// EFESTO-TODOmarkers — the JUDGMENT residue (handlers, express-validator → TypeBox, non-portable cache keys). - Centralise
swaggerModelintoefesto({ models }), and add anonErrorthat mapserr.status(see the bootstrap template above). - Type-check and verify at runtime — Bun does not type-check, so run
tsc --noEmit, then start the server and exercise the migrated routes (cache, uploads, errors).
tsc --noEmit mattersBun runs TypeScript without type-checking, so the codemod deliberately leaves loud compile
errors (e.g. an import of a non-exported type) next to the relevant marker. Running
tsc --noEmit surfaces them — it is your real safety gate, not the runtime.
See the Bun / Elysia stack guide for the destination model in full.