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:

  • Import — from "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 access — req.<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.

_id → id, 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.