From Fastify to Elysia
This guide is for Fastify users who want to see the differences from Fastify including syntax, and how to migrate your application from Fastify to Elysia by example.
Fastify is a fast and low overhead web framework for Node.js, designed to be simple and easy to use. It is built on top of the HTTP module and provides a set of features that make it easy to build web applications.
Elysia is an ergonomic web framework for Bun, Node.js, and runtimes that support Web Standard API. Designed to be ergonomic and developer-friendly with a focus on sound type safety and performance.
Performance
Section titled “Performance”Elysia has significant performance improvements over Fastify thanks to native Bun implementation, and static code analysis.
This comparison uses a historical TechEmpower Round 22 result from 2023-10-17 for Plaintext. These are the recorded framework/runtime combinations, not measurements of Elysia v2.
| Framework | Requests per second |
|---|---|
| Elysia | 2,454,631 |
| Fastify | 415,600 |
Routing
Section titled “Routing”Fastify and Elysia have similar routing syntax, using app.get() and app.post() methods to define routes and similar path parameter syntax.
import fastify from 'fastify'
const app = fastify()
app.get('/', (request, reply) => { reply.send('Hello World')})
app.post('/id/:id', (request, reply) => { reply.status(201).send(request.params.id)})
app.listen({ port: 3000 })Fastify uses
requestandreplyas request and response objects
import { Elysia } from 'elysia'
const app = new Elysia() .get('/', 'Hello World') .post( '/id/:id', ({ status, params: { id } }) => { return status(201, id) } ) .listen(3000)Elysia uses a single
contextand returns the response directly
There is a slight difference in style guide. Elysia recommends usage of method chaining and object destructuring.
Elysia also supports an inline value for the response if you don’t need to use the context.
Handler
Section titled “Handler”Both have similar properties for accessing input parameters like headers, query, params, and body. Elysia automatically parses the request body as JSON, URL-encoded data, and formdata, while Fastify parses JSON and needs @fastify/formbody and @fastify/multipart for the other two.
import fastify from 'fastify'
const app = fastify()
app.post('/user', (request, reply) => { const limit = request.query.limit const name = request.body.name const auth = request.headers.authorization
reply.send({ limit, name, auth })})Fastify parses data and puts it into
requestobject
import { Elysia, t } from 'elysia'
const app = new Elysia() .post('/user', { body: t.Object({ name: t.String() }) }, (ctx) => { const limit = ctx.query.limit const name = ctx.body.name const auth = ctx.headers.authorization
return { limit, name, auth } })Elysia parses data and puts it into
contextobject
Subrouter
Section titled “Subrouter”Fastify uses a function callback to define a subrouter while Elysia treats every instance as a component that can be plugged and played together.
import fastify, { FastifyPluginCallback } from 'fastify'
const subRouter: FastifyPluginCallback = (app, opts, done) => { app.get('/user', (request, reply) => { reply.send('Hello User') })
done()}
const app = fastify()
app.register(subRouter, { prefix: '/api'})Fastify uses a function callback to declare a sub router
import { Elysia } from 'elysia'
const subRouter = new Elysia({ prefix: '/api' }) .get('/user', 'Hello User')
const app = new Elysia() .use(subRouter)Elysia treats every instance as a component
While Elysia sets the prefix in the constructor, Fastify requires you to set the prefix in the options.
Validation
Section titled “Validation”Elysia has built-in support for request validation with sound type safety out of the box using TypeBox while Fastify uses JSON Schema for declaring schemas, and ajv for validation.
However, it doesn’t infer types automatically, and you need to use a type provider like @fastify/type-provider-json-schema-to-ts to infer types.
import fastify from 'fastify'import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'
const app = fastify().withTypeProvider<JsonSchemaToTsProvider>()
app.patch( '/user/:id', { schema: { params: { type: 'object', properties: { id: { type: 'string', pattern: '^[0-9]+$' } }, required: ['id'] }, body: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, } }, (request, reply) => { // map string to number request.params.id = +request.params.id
reply.send({ params: request.params, body: request.body }) })Fastify uses JSON Schema for validation
import { Elysia, t } from 'elysia'
const app = new Elysia() .patch('/user/:id', { params: t.Object({ id: t.Number() }), body: t.Object({ name: t.String() }) }, ({ params, body }) => ({ params, body }))import { Elysia } from 'elysia'import { z } from 'zod'
const app = new Elysia() .patch('/user/:id', { params: z.object({ id: z.coerce.number() }), body: z.object({ name: z.string() }) }, ({ params, body }) => ({ params, body }))import { Elysia } from 'elysia'import * as v from 'valibot'
const app = new Elysia() .patch('/user/:id', { params: v.object({ id: v.pipe(v.string(), v.transform(Number), v.number()) }), body: v.object({ name: v.string() }) }, ({ params, body }) => ({ params, body }))Elysia uses TypeBox for validation, and coerces types automatically, while supporting various validation libraries like Zod and Valibot with the same syntax as well.
Alternatively, Fastify can also use TypeBox or Zod for validation using @fastify/type-provider-typebox to infer types automatically.
While Elysia prefers TypeBox for validation, Elysia also supports Standard Schema allowing you to use libraries like Zod, Valibot, ArkType, Effect Schema and so on out of the box.
File upload
Section titled “File upload”Fastify uses fastify-multipart to handle file upload which uses Busboy under the hood while Elysia uses Web Standard API for handling formdata, mimetype validation using declarative API.
However, Fastify doesn’t offer a straightforward way for file validation, eg. file size and mimetype, and requires some workarounds to validate the file.
import fastify from 'fastify'import multipart from '@fastify/multipart'
import { fileTypeFromBuffer } from 'file-type'
const app = fastify()app.register(multipart, { attachFieldsToBody: 'keyValues'})
app.post( '/upload', { schema: { body: { type: 'object', properties: { file: { type: 'object' } }, required: ['file'] } } }, async (req, res) => { const file = req.body.file if (!file) return res.status(422).send('No file uploaded')
const type = await fileTypeFromBuffer(file) if (!type || !type.mime.startsWith('image/')) return res.status(422).send('File is not a valid image')
res.header('Content-Type', type.mime) res.send(file) })Fastify uses
fastify-multipartto handle file uploads and uses a faketype: objectto allow a Buffer
import { Elysia, t, setFileTypeDetector } from 'elysia'import { fileTypeFromBlob } from 'file-type'
setFileTypeDetector(fileTypeFromBlob)
const app = new Elysia() .post('/upload', { body: t.Object({ file: t.File({ type: 'image/*' }) }) }, ({ body }) => body.file)Elysia handles files and mimetype validation using
t.File
File metadata is not proof of the file’s contents. The example uses file-type to inspect its bytes.
Elysia validates the file schema. Register the file-type detector as shown to validate the file bytes.
Lifecycle Event
Section titled “Lifecycle Event”Both Fastify and Elysia have somewhat similar lifecycle events using an event-based approach.
Elysia Lifecycle
Section titled “Elysia Lifecycle”Elysia exposes stages of the request lifecycle:
| Stage | Purpose |
|---|---|
| Request | Inspect the incoming request before parsing |
| Parse | Decode the request body |
| Transform and validation | Normalize and validate request inputs |
| Before handle | Derive request values or return an early response |
| Handler | Produce the route response |
| After handle and map response | Adjust and serialize the response |
| After response | Run work after the response is sent |
The error hook handles failures in this pipeline.
Fastify Lifecycle
Section titled “Fastify Lifecycle”Fastify lifecycle events also use an event-based approach similar to Elysia.
Both also have somewhat similar syntax for intercepting the request and response lifecycle events. However, Elysia doesn’t require you to call done to continue the lifecycle event.
import fastify from 'fastify'
const app = fastify()
// Global middlewareapp.addHook('onRequest', (request, reply, done) => { console.log(`${request.method} ${request.url}`)
done()})
app.get( '/protected', { // Route-specific middleware preHandler(request, reply, done) { const token = request.headers.authorization
if (!token) return reply.status(401).send('Unauthorized')
done() } }, (request, reply) => { reply.send('Protected route') })Fastify uses
addHookto register middleware, and requires you to calldoneto continue the lifecycle event
import { Elysia } from 'elysia'
const app = new Elysia() // Global middleware .onRequest(({ request }) => { console.log(request.method, new URL(request.url).pathname) }) // Route-specific middleware .get('/protected', { beforeHandle({ status, headers }) { if (!headers.authorization) return status(401) } }, () => 'protected')Elysia detects the lifecycle event automatically, and doesn’t require you to call
doneto continue the lifecycle event
Sound type safety
Section titled “Sound type safety”Elysia is designed to provide sound type safety.
For example, derive adds request-specific context with inferred types. In v2 it also takes over the validated-context role previously served by resolve.
import fastify from 'fastify'
const app = fastify()
app.decorateRequest('version', 2)
app.get('/version', (req, res) => { // Error: Property 'version' does not exist on type 'FastifyRequest' res.send(req.version)})
app.get( '/token', { preHandler(req, res, done) { const token = req.headers.authorization
if (!token) return res.status(401).send('Unauthorized')
// @ts-ignore req.token = token.split(' ')[1]
done() } }, (req, res) => { // Error: Property 'version' does not exist on type 'FastifyRequest' req.version
// Error: Property 'token' does not exist on type 'FastifyRequest' res.send(req.token) })
app.listen({ port: 3000})Fastify uses
decorateRequestbut doesn’t offer sound type safety
import { Elysia } from 'elysia'
const app = new Elysia() .decorate('version', 2) .get('/version', ({ version }) => version) .derive(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401)
return { token: authorization.split(' ')[1] } }) // { token: string, version: number } .get('/token', ({ token, version }) => { return token })Elysia uses
decorateto extend the context, andderiveto add custom properties to the context
While Fastify can use declare module to extend the FastifyRequest interface, it is globally available and doesn’t have sound type safety, and doesn’t guarantee that the property is available in all request handlers.
declare module 'fastify' { interface FastifyRequest { version: number token: string }}This is required for the above Fastify example to work, which doesn’t offer sound type safety
Middleware parameter
Section titled “Middleware parameter”Fastify uses a function to return a Fastify plugin to define named middleware, while Elysia uses macro to define a custom hook.
import { findUser } from './users'import fastify from 'fastify'import type { FastifyRequest, FastifyReply } from 'fastify'
const app = fastify()
const role = (role: 'user' | 'admin') => (request: FastifyRequest, reply: FastifyReply, next: Function) => { const user = findUser(request.headers.authorization)
if (!user || user.role !== role) return reply.status(401).send('Unauthorized')
// Error: Property 'user' does not exist on type 'FastifyRequest' request.user = user
next() }
app.get( '/token', { preHandler: role('admin') }, (request, reply) => { // Error: Property 'user' does not exist on type 'FastifyRequest' reply.send(request.user) })Fastify uses a function callback to accept custom arguments for middleware
import { Elysia } from 'elysia'import { findUser } from './users'
const app = new Elysia() .macro({ role: (role: 'user' | 'admin') => ({ derive({ status, headers: { authorization } }) { const user = findUser(authorization)
if (!user || user.role !== role) return status(401)
return { user } } }) }) // { user: User } .get('/token', { role: 'admin' }, ({ user }) => user)Elysia uses macro to pass custom arguments to custom middleware
Here, findUser is application code that verifies the supplied credential and returns a user with a role, or no user when verification fails. A header or Bearer prefix check alone is not authentication.
While Fastify uses a function callback, it needs to return a function to be placed in an event handler or an object represented as a hook which can be hard to handle when there is a need for multiple custom functions as you need to reconcile them into a single object.
Error handling
Section titled “Error handling”Both Fastify and Elysia offer a lifecycle event to handle errors.
import fastify from 'fastify'
const app = fastify()
class CustomError extends Error { constructor(message: string) { super(message) this.name = 'CustomError' }}
// global error handlerapp.setErrorHandler((error, request, reply) => { if (error instanceof CustomError) reply.status(500).send({ message: 'Something went wrong!', error })})
app.get( '/error', { // route-specific error handler errorHandler(error, request, reply) { reply.send({ message: 'Only for this route!', error }) } }, (request, reply) => { throw new CustomError('oh uh') })Fastify uses
setErrorHandlerfor a global error handler, anderrorHandlerfor a route-specific error handler
import { Elysia } from 'elysia'
class CustomError extends Error { status = 500}
const app = new Elysia() .onError(CustomError, ({ error, status }) => status(500, { message: 'Something went wrong!', detail: error.message }) ) .get('/error', () => { throw new CustomError('oh uh') }) .get('/local-error', { error({ error, status }) { if (error instanceof RangeError) return status(500, { message: 'Only for this route!' }) } }, () => { throw new RangeError('outside the allowed range') })Elysia offers class-specific error handlers and a helper for HTTP status responses.
While both offer error handling using lifecycle events, Elysia also provides:
- Class-specific error handlers with inferred error types
- The
statushelper for returning a response with an HTTP status
The /error route uses the registered CustomError handler. The /local-error route handles a different error locally. Error hooks run in the order they’re registered, and the first one that returns a value sends the response. Hooks registered on the app before a route run before that route’s own error hook, so the route hook can’t override them.
Elysia infers the registered error type in its class-specific handler.
Encapsulation
Section titled “Encapsulation”Fastify encapsulates plugin side-effects, while Elysia gives you control over side-effects of a plugin via an explicit scoping mechanism and order-of-code.
import fastify from 'fastify'import type { FastifyPluginCallback } from 'fastify'
const subRouter: FastifyPluginCallback = (app, opts, done) => { app.addHook('preHandler', async (request, reply) => { if (!request.headers.authorization?.startsWith('Bearer ')) return reply.code(401).send({ error: 'Unauthorized' }) })
done()}
const app = fastify() .get('/', (request, reply) => { reply.send('Hello World') }) .register(subRouter) // doesn't have side-effect from subRouter .get('/side-effect', () => 'hi')Fastify encapsulates side-effects of a plugin
import { Elysia } from 'elysia'
const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) })
const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // doesn't have side-effect from subRouter .get('/side-effect', () => 'hi')Elysia encapsulates side-effects of a plugin unless explicitly stated
Both have an encapsulation mechanism of a plugin to prevent side-effects.
However, Elysia can explicitly state which plugin should have side-effects by declaring plugin scope, while Fastify encapsulates by default and needs fastify-plugin to share side-effects.
import { Elysia } from 'elysia'
const subRouter = new Elysia() .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) // Scoped to parent instance but not beyond .as('plugin') // [!code ++]
const app = new Elysia() .get('/', 'Hello World') .use(subRouter) // [!code ++] // now have side-effect from subRouter .get('/side-effect', () => 'hi')Elysia offers 3 types of scoping mechanisms:
- local - Apply to this instance and its descendants (default)
- plugin - Share side-effects with the parent instance but not beyond
- global - Share through all parent instances
To share hooks beyond Fastify’s default encapsulation boundary, the example below creates a function for each hook and appends it manually.
However, this can cause duplicated side-effects if not handled carefully.
import fastify from 'fastify'import type { FastifyRequest, FastifyReply, FastifyPluginCallback} from 'fastify'
const log = (request: FastifyRequest, reply: FastifyReply, done: Function) => { console.log('Middleware executed')
done()}
const app = fastify()
app.addHook('onRequest', log)app.get('/main', (request, reply) => { reply.send('Hello from main!')})
const subRouter: FastifyPluginCallback = (app, opts, done) => { app.addHook('onRequest', log)
// This would log twice app.get('/sub', (request, reply) => { return reply.send('Hello from sub router!') })
done()}
app.register(subRouter, { prefix: '/sub'})
app.listen({ port: 3000})In this scenario, Elysia offers a plugin deduplication mechanism to prevent duplicated side-effects.
import { Elysia } from 'elysia'
const subRouter = new Elysia({ name: 'subRouter' }) // [!code ++] .onBeforeHandle(({ status, headers: { authorization } }) => { if(!authorization?.startsWith('Bearer ')) return status(401) }) .as('plugin')
const app = new Elysia() .get('/', 'Hello World') .use(subRouter) .use(subRouter) // [!code ++] .use(subRouter) // [!code ++] .use(subRouter) // [!code ++] // side-effect only called once .get('/side-effect', () => 'hi')By using a unique name, Elysia will apply the plugin only once, and will not cause duplicated side-effects.
Cookie
Section titled “Cookie”Fastify uses @fastify/cookie to parse cookies, while Elysia has built-in support for cookies and uses a signal-based approach to handle cookies.
import fastify from 'fastify'import cookie from '@fastify/cookie'
const app = fastify()
app.register(cookie, { secret: 'secret', hook: 'onRequest'})
app.get('/', function (request, reply) { request.unsignCookie(request.cookies.name)
reply.setCookie('name', 'value', { path: '/', signed: true })})Fastify uses
unsignCookieto verify the cookie signature, andsetCookieto set the cookie
import { Elysia, t } from 'elysia'
const app = new Elysia({ cookie: { secrets: 'replace-with-a-private-secret', sign: ['name'] }}) .get('/', { cookie: t.Cookie({ name: t.Optional(t.String()) }) }, ({ cookie: { name } }) => { const previous = name.value name.set({ value: 'value', maxAge: 60 * 60 * 24, httpOnly: true, secure: true, sameSite: 'lax' }) return previous ?? 'unset' })Elysia verifies and signs the configured cookies automatically. Cookie
maxAgeis in seconds; replace the example secret with a private value and serve secure cookies over HTTPS.
OpenAPI
Section titled “OpenAPI”Both offer OpenAPI documentation using Swagger. However, Elysia defaults to Scalar UI which is a more modern and user-friendly interface for OpenAPI documentation.
import fastify from 'fastify'import swagger from '@fastify/swagger'
const app = fastify()app.register(swagger, { openapi: { info: { title: 'My API', version: '1.0.0' } }})
app.addSchema({ $id: 'user', type: 'object', properties: { name: { type: 'string', description: 'First name only' }, age: { type: 'integer' } }, required: ['name', 'age']})
app.post( '/users', { schema: { summary: 'Create user', body: { $ref: 'user#' }, response: { '201': { $ref: 'user#' } } } }, (req, res) => { res.status(201).send(req.body) })
await app.ready()app.swagger()Fastify uses
@fastify/swaggerfor OpenAPI documentation using Swagger
import { Elysia, t } from 'elysia'import { openapi } from '@elysia/openapi' // [!code ++]
const app = new Elysia() .use(openapi()) // [!code ++] .model({ user: t.Object({ name: t.String(), age: t.Integer() }) }) .post('/users', { body: 'user', response: { 201: 'user' }, detail: { summary: 'Create user' } }, ({ body, status }) => status(201, body))Elysia uses
@elysia/openapifor OpenAPI documentation with Scalar by default.
Both offer model reference using $ref for OpenAPI documentation. However, Fastify doesn’t offer type-safety, and auto-completion for specifying model names while Elysia does.
Testing
Section titled “Testing”Fastify tests handlers in process with fastify.inject(). Elysia accepts a Web Standard Request through app.handle(). Neither approach needs a listening network server.
import fastify from 'fastify'import { describe, it, expect } from 'vitest'
function build(opts = {}) { const app = fastify(opts)
app.get('/', async function (request, reply) { reply.send({ hello: 'world' }) })
return app}
describe('GET /', () => { it('should return the greeting', async () => { const app = build()
const response = await app.inject({ url: '/', method: 'GET', })
expect(response.statusCode).toBe(200) expect(response.json()).toEqual({ hello: 'world' }) })})Fastify uses
fastify.inject()to simulate a network request
import { Elysia } from 'elysia'import { describe, it, expect } from 'vitest'
const app = new Elysia() .get('/', () => ({ hello: 'world' }))
describe('GET /', () => { it('should return the greeting', async () => { const res = await app.handle( new Request('http://localhost') )
expect(res.status).toBe(200) expect(await res.json()).toEqual({ hello: 'world' }) })})Elysia uses Web Standard API to handle requests
Alternatively, Elysia also offers a helper library called Eden for End-to-end type safety, allowing us to test with auto-completion, and full type safety.
import { Elysia } from 'elysia'import { treaty } from '@elysia/eden'import { describe, expect, it } from 'bun:test'
const app = new Elysia().get('/hello', 'Hello World')const api = treaty(app)
describe('GET /', () => { it('should return Hello World', async () => { const { data, error, status } = await api.hello.get()
expect(status).toBe(200) expect(data).toBe('Hello World') })})End-to-end type safety
Section titled “End-to-end type safety”Elysia offers built-in support for end-to-end type safety without code generation using Eden, while Fastify doesn’t offer one.
import { Elysia, t } from 'elysia'import { treaty } from '@elysia/eden'
const app = new Elysia() .post('/mirror', { body: t.Object({ message: t.String() }) }, ({ body }) => body)
const api = treaty(app)
const { data, error } = await api.mirror.post({ message: 'Hello World'})
if (error) // error.status: 422 // error.value: { type: 'validation', title: 'Validation Error', status: 422, on: 'body', detail, errors: [...] } throw error
// { message: 'Hello World' }console.log(data)If end-to-end type safety is important for you, then Elysia is the right choice.
Elysia offers a more ergonomic and developer-friendly experience with a focus on performance, type safety, and simplicity while Fastify is one of the established frameworks for Node.js, but doesn’t have sound type safety and end-to-end type safety offered by next generation frameworks.
If you are looking for a framework that is easy to use, has a great developer experience, and is built on top of Web Standard API, Elysia is the right choice for you.
Alternatively, if you are coming from a different framework, you can check out: