Elysia 1.1 – Grown‑up’s Paradise
(Blog post by saltyaom – 16 Jul 2024)

Elysia 1.1 focuses on several improvements to developer experience:
- OpenTelemetry
- Trace v2 (breaking change)
- Normalization
- Data type coercion
- Guard as
- Bulk
ascast - Response status reconciliation
- Optional path parameter
- Generator response stream
Named after a song by Mili, “Grown‑up’s Paradise”, and used as opening for the commercial announcement of Arknights TV animation season 3.
As a day‑one Arknights player and long‑time Mili fan, you should check them out – they’re the goat.
OpenTelemetry
Observability is a vital aspect for production.
It allows us to understand how our server works, identify problems and bottlenecks.
OpenTelemetry is the most popular tool, but it can be hard to set up and instrument a server correctly, especially for existing frameworks.
Elysia introduces first‑party support for OpenTelemetry.
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
new Elysia()
.use(
opentelemetry({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter()
)
]
})
)

Elysia OpenTelemetry will collect span of any library compatible with the OpenTelemetry standard, and will automatically apply parent/child span relationships.
In the code above we also show how to trace a Prisma query.
You can export telemetry data to Jaeger, Zipkin, New Relic, Axiom, or any other OpenTelemetry‑compatible backend.
Here’s an example of exporting to Axiom:
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
new Elysia()
.use(
opentelemetry({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: 'https://api.axiom.co/v1/traces',
headers: {
Authorization: `Bearer ${Bun.env.AXIOM_TOKEN}`,
'X-Axiom-Dataset': Bun.env.AXIOM_DATASET
}
})
)
]
})
)

Elysia OpenTelemetry is only for the Elysia server.
The SDK can still be used normally; spans will run under the request span and appear in the trace.
You can also use getTracer and record to collect a span from any part of your application:
import { Elysia } from 'elysia'
import { record } from '@elysiajs/opentelemetry'
export const plugin = new Elysia()
.get('', () =>
record('database.query', () =>
db.query('SELECT * FROM users')
)
)
record is equivalent to OpenTelemetry's startActiveSpan but automatically
closes the span and captures exceptions.
Prepare your codebase for observability
Elysia OpenTelemetry names each hook’s span after the hook’s function name.
If the handler is an arrow function, the span will be called anonymous.
const bad = new Elysia()
// ⚠️ span name will be anonymous
.derive(async ({ cookie: { session } }) => {
return { user: await getProfile(session) }
})
const good = new Elysia()
// ✅ span name will be getProfile
.derive(async function getProfile({ cookie: { session } }) {
return { user: await getProfile(session) }
})
Trace v2 (breaking change)
Elysia OpenTelemetry is built on Trace v2, replacing Trace v1.
Trace v2 allows you to trace any part of your server with 100 % synchronous behavior, instead of relying on a parallel event‑listener bridge (goodbye dead lock).
It is rewritten for speed, reliability, and microsecond accuracy via Elysia’s ahead‑of‑time compilation and code injection.
import { Elysia } from 'elysia'
new Elysia()
.trace(({ onBeforeHandle, set }) => {
// Listen to before handle event
onBeforeHandle(({ onEvent }) => {
// Listen to all child events in order
onEvent(({ onStop, name }) => {
// Execute something after a child event is finished
onStop(({ elapsed }) => {
console.log(name, 'took', elapsed, 'ms')
// callback is executed synchronously before next event
set.headers['x-trace'] = 'true'
})
})
})
})
You can also use async inside a trace; Elysia will block until the callback finishes.
Trace v2 is a breaking change to Trace v1 – see the trace API documentation for more details.
Normalization
Elysia 1.1 normalizes data before processing it. It coerces data into the exact shape defined in the schema, removes extra fields, and normalizes the format.
Example with Eden:
import { Elysia, t } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia()
.post('/', ({ body }) => body, {
body: t.Object({
name: t.String(),
point: t.Number()
}),
response: t.Object({
name: t.String()
})
})
const { data } = await treaty(app).index.post({
name: 'SaltyAom',
point: 9001, // ⚠️ additional field
title: 'maintainer' // will be removed
})
// { name: 'SaltyAom' }
The code above:
- Removes
titlefrom the request body. - Removes
pointfrom the response before it is sent to the client.
This prevents data inconsistency and sensitive data leaks.
Data type coercion
Before 1.1, you had to explicitly cast query parameters with t.Numeric.
Now, coercion automatically converts values to the correct type.
// Without coercion
const app = new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
page: t.Numeric()
})
})
// With coercion – use t.Number instead of t.Numeric
const app = new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
// ✅ page will be coerced into a number automatically
page: t.Number()
})
})
Coercion applies to t.Boolean, t.Object, t.Array, etc.
It works by swapping the schema with its coercion counterpart during Elysia’s compilation phase.
Guard as
Previously guard applied only to the current instance.
const plugin = new Elysia()
.guard({
beforeHandle() {
console.log('called')
}
})
.get('/plugin', () => 'ok')
const main = new Elysia()
.use(plugin)
.get('/', () => 'ok')
In 1.1 you can add an as property ('scoped' or 'global') to guard to control
its scope, just like event listeners.
const plugin1 = new Elysia()
.guard({
as: 'scoped',
beforeHandle() {
console.log('called')
}
})
.get('/plugin', () => 'ok')
// Same as
const plugin2 = new Elysia()
.onBeforeHandle({ as: 'scoped' }, () => {
console.log('called')
})
.get('/plugin', () => 'ok')
The as property also allows applying a schema to all routes at once:
const plugin = new Elysia()
.guard({
as: 'scoped',
response: t.String()
})
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)
const instance = new Elysia()
.use(plugin)
const parent = new Elysia()
.use(instance)
.get('/ok', () => 3)
Bulk as cast
When using multiple plugins, scoping can be limited to a single parent.
To lift a plugin’s scope up to a parent instance, use the as('plugin') cast.
const plugin = new Elysia()
.guard({
as: 'scoped',
response: t.String()
})
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)
const instance = new Elysia()
.use(plugin)
instance
.as('plugin') // lift the scope
.get('/ok', () => 2)
(Continued in the original post…)