Skip to main content

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:

  • Importfrom "efesto"from "efesto/elysia".
  • Handler signature(req, res, next) → destructuring of only the fields used, e.g. ({ body, params }). Applied only when req is used exclusively as req.{body,params,query,headers}, res only inside return res.json(x), and next is never used.
  • Field accessreq.<field><field>, and return res.json(x)return x.
  • Generated types — references to generated types (*Types.*) are stripped from the handler signature (the native Context / 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:

MarkerWhy it can't be automatic
Non-standard handlerres.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 → TypeBoxA _<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).
swaggerModelThe service-level swaggerModel is ignored on the Elysia stack — lift its schemas into the central efesto({ models }) registry.
Non-portable cache keyskey/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 importsimport 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.

ConceptExpressElysiaType
Importfrom "efesto"from "efesto/elysia"MECHANICAL
Base classextends BaseApiServiceextends BaseApiServiceIDENTICAL
Constructorsuper(__filename)optional, ignored (path derived from the file)MECHANICAL
Verb convention_get, _getSwagger, _getValidation, _getMulteridenticalIDENTICAL
Swagger DSL_<verb>Swagger: SwaggerOptions (@Model, $ref, isPopulation…)identical (same expandSwagger)IDENTICAL
swaggerModelon the serviceignored → lift into efesto({ models })REWRITE

Handler

ExpressElysiaType
async _get(req, res, next)_get(ctx: Context)JUDGMENT
req.body / req.params / req.querydestructured from Context: ({ body, params, query }) =>MECHANICAL
return res.json(x)return xMECHANICAL
res.status(n).json(x)({ set }) => { set.status = n; return x }JUDGMENT
res.send / res.redirect / manual headersElysia API (set.headers, redirect, raw return)JUDGMENT
next(err) / throwthrow (handled by onError)MECHANICAL
Generated types ModelXTypes.*native Context (+ TypeBox)JUDGMENT

Validation

ExpressElysiaType
_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

ExpressElysiaType
_putMulter = true / _putMultipleMulter = trueidentical; without a _<verb>Validation.body, Efesto auto-injects t.Object({ file: t.File() }) / t.Object({ files: t.Files() })IDENTICAL
req.file / req.filesbody.file / body.files (web File)MECHANICAL
(precedence)declaring _<verb>Validation.body overrides the multer auto-body — re-declare files there yourselfJUDGMENT

See File Uploads.

Cache / purge

ExpressElysiaType
config.redis: { host, port, defaultExpiresInSeconds }efesto({ redis: {...} }) or redisClientREWRITE
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.xparams.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 keyJUDGMENT

See Caching.

Permissions / ABAC

ExpressElysiaType
abacPermissions in config + req.ability via middlewareabac in efesto({ abac }) + ability via setup: app => app.derive(() => ({ ability }))REWRITE
abacPermissions.reqAbilityFieldabac.abilityField (default "ability")MECHANICAL
abacPermissions.checkPermissionBeforeResolverabac.checkPermissionBeforeResolver (default true)MECHANICAL
permission shape { action, model }permission: [action, model]JUDGMENT (different shape)

See ABAC Permissions.

_idid, routing, runtime

ExpressElysiaType
Efesto.mongoIdParser globalefesto({ mongoIdParser: true }) (off by default)MECHANICAL
routes/user/[id].ts/user/:ididenticalIDENTICAL
__dirname / __filenameimport.meta.dirMECHANICAL
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/compressionefesto({...}).listen(port) (cors etc. via setup or Elysia plugins)
authMiddleware / errorMiddlewaresetup: app => app.onBeforeHandle(...).onError(...)
req.ability = defineAbility(...) middlewaresetup: app => app.derive(() => ({ ability }))
swagger via swagger-ui-expressnative @elysiajs/openapi (served at /<prefix>/openapi)

After the codemod

The codemod handles the MECHANICAL part. To get to a correct runtime, do these once:

  1. Resolve the // EFESTO-TODO markers — the JUDGMENT residue (handlers, express-validator → TypeBox, non-portable cache keys).
  2. Centralise swaggerModel into efesto({ models }), and add an onError that maps err.status (see the bootstrap template above).
  3. 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).
Why tsc --noEmit matters

Bun 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.