Skip to content

Key concepts

Important

We recommend finishing the Essential concepts chapters before starting your first Elysia project.

But if you want to jump straight into writing code, this is a brief overview of Elysia’s key concepts.

Elysia lifecycle hooks are encapsulated. This means a hook added in a plugin will not apply to the instance that uses the plugin unless you explicitly share it.

import { Elysia } from 'elysia'
import { throwIfNotSignIn, updateProfile } from './profile'
const profile = new Elysia()
.onBeforeHandle(({ cookie }) => {
throwIfNotSignIn(cookie)
})
.get('/profile', () => 'Hi there!')
const app = new Elysia()
.use(profile)
// This route does not inherit the local sign-in check.
.patch('/rename', ({ body }) => updateProfile(body))

Here, the throwIfNotSignIn is applied to /profile but not to /rename.


To make it carry through, you need to tell Elysia to share it.

As with the export keyword, make the sharing explicit when another instance needs it.

To export the hook, simply add a scope:

import { Elysia } from 'elysia'
import { throwIfNotSignIn, updateProfile } from './profile'
const profile = new Elysia()
.onBeforeHandle('global', ({ cookie }) => {
throwIfNotSignIn(cookie)
})
.get('/profile', () => 'Hi there!')
const app = new Elysia()
.use(profile)
// This route inherits the sign-in check.
.patch('/rename', ({ body }) => updateProfile(body))

Use method chaining to keep Elysia’s inferred types:

import { Elysia } from 'elysia'
new Elysia()
.state('build', 1)
.get('/', ({ store: { build } }) => build)
.listen(3000)

state returns an instance type with a typed build property. Chaining the next call keeps it available.

If you don’t use method chaining, it doesn’t keep the new type. This example intentionally produces a TypeScript error:

import { Elysia } from 'elysia'
const app = new Elysia()
app.state('build', 1)
app.get('/', ({ store: { build } }) => build) //! Property 'build' does not exist on type '{}'
app.listen(3000)

The instance can still be changed at runtime; the problem is that the variable’s static type hasn’t changed. Chain calls, or retain the returned instance in a new variable.

Every instance has its own context.

When an instance needs another instance’s context, it’s treated as a dependency via .use().

import { Elysia, t } from 'elysia'
class Auth {
getProfile() {
return { name: 'Elysia User' }
}
}
const auth = new Elysia()
.decorate({
auth: new Auth()
})
const main = new Elysia()
// Intentional type error: auth has not been registered here.
.get('/', ({ auth }) => auth.getProfile()) //! Property 'auth' does not exist on type '{ body: unknown; ... store: {}; }'
.use(auth)
.get('/profile', ({ auth }) => auth.getProfile())

This is Context Propagation. Each instance has its own context, and .use() propagates the context from one instance to another. The auth instance is a dependency of main, so main can use its services and types.

It’s intentionally designed to be explicit and have no global type context because:

  1. Type soundness: To guarantee types both at runtime and at the type level, Elysia needs to know the dependency explicitly. Declaring a global type context would break this guarantee.
  2. Dependency Tracking: When the dependent is changed, Elysia can track the dependency and update the dependent’s type context accordingly.
  3. Modularity: Any part should be able to be used independently without any global type context.

A plugin can be registered more than once when multiple instances share it. To prevent duplication, give the plugin a unique name and Elysia will deduplicate it for you.

import { Elysia } from 'elysia'
const ip = new Elysia({ name: 'ip' })
.derive('global', ({ server, request }) => ({
ip: server?.requestIP(request)
}))
.get('/ip', ({ ip }) => ip)
const router1 = new Elysia()
.use(ip)
.get('/ip-1', ({ ip }) => ip)
const router2 = new Elysia()
.use(ip)
.get('/ip-2', ({ ip }) => ip)
const server = new Elysia()
.use(router1)
.use(router2)

This makes the shared derive hook run once per request.

For a configurable plugin, add seed to identify its configuration.

Matching name and seed values deduplicate registrations. Different seeds keep configurations distinct.

Learn more about plugin deduplication.

For routes declared directly on an instance, hooks apply to routes registered after them:

import { Elysia } from 'elysia'
new Elysia()
.onBeforeHandle(() => {
console.log('1')
})
.get('/', () => 'hi')
.onBeforeHandle(() => {
console.log('2')
})
.listen(3000)

A request to / logs:

1

It doesn’t log 2 because that hook was registered after the direct route.

Keep shared hooks near the start of an instance. See order of code for the direct-route and mounted-plugin examples.

Elysia infers handler context from the route’s schema:

import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/', {
body: t.Object({ name: t.String() })
}, ({ body }) => body)

Use an inline handler so TypeScript can infer context at the route declaration.

When you need a separate function, such as a controller method, pass only the values it needs through the inline handler:

import { Elysia, t } from 'elysia'
abstract class Controller {
static greet({ name }: { name: string }) {
return 'hello ' + name
}
}
const app = new Elysia()
.post('/', {
body: t.Object({ name: t.String() })
}, ({ body }) => Controller.greet(body))

See best practice: controller.

Type to search the documentation.

    Up arrowDown arrow to navigateEnter to selectEscape to close