From tRPC to Elysia
This guide is for tRPC users who want to see a differences from Elysia including syntax, and how to migrate your application from tRPC to Elysia by example.
tRPC is a typesafe RPC framework for building APIs using TypeScript. It provides a way to create end-to-end type-safe APIs with type-safe contract between frontend and backend.
Elysia is an ergonomic web framework. Designed to be ergonomic and developer-friendly with a focus on sound type safety and performance.
Overview
tRPC is primarily designed as RPC communication with proprietary abstraction over RESTful API, while Elysia is focused on RESTful API.
Main feature of tRPC is end-to-end type safety contract between frontend and backend which Elysia also offers via Eden.
Making Elysia a better fit for building a universal API with RESTful standard that developers already know instead of learning a new proprietary abstraction while having the end-to-end type safety that tRPC offers.
Routing
Elysia use a syntax similar to Express, and Hono like app.get() and app.post() methods to define routes and similar path parameters syntax.
While tRPC use a nested router approach to define routes.
::: code-group
import { initTRPC } from '@trpc/server'
import { createHTTPServer } from '@trpc/server/adapters/standalone'
const t = initTRPC.create()
const appRouter = t.router({
hello: t.procedure.query(() => 'Hello World'),
user: t.router({
getById: t.procedure
.input((id: string) => id)
.query(({ input }) => {
return { id: input }
})
})
})
const server = createHTTPServer({
router: appRouter
})
server.listen(3000)
:::
tRPC use nested router and procedure to define routes
::: code-group
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', 'Hello World')
.post(
'/id/:id',
({ status, params: { id } }) => {
return status(201, id)
}
)
.listen(3000)
:::
Elysia use HTTP method, and path parameters to define routes
While tRPC use proprietary abstraction over RESTful API with procedure and router, Elysia use a syntax similar to Express, and Hono like app.get() and app.post() methods to define routes and similar path parameters syntax.
Handler
tRPC handler is called procedure which can be either query or mutation, while Elysia use HTTP method like get, post, put, delete and so on.
tRPC is doesn't have a concept of HTTP property like query, headers, status code, and so on, only input and output.
::: code-group
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
const appRouter = t.router({
user: t.procedure
.input((val: { limit?: number; name: string; authorization?: string }) => val)
.mutation(({ input }) => {
const limit = input.limit
const name = input.name
const auth = input.authorization
return { limit, name, auth }
})
})
:::
tRPC use single
inputfor all properties
::: code-group
import { Elysia } from 'elysia'
const app = new Elysia()
.post('/user', (ctx) => {
const limit = ctx.query.limit
const name = ctx.body.name
const auth = ctx.headers.authorization
return { limit, name, auth }
})
:::
Elysia use specific property for each HTTP property
Elysia use static code analysis to determine what to parse, and only parse the required properties.
This is useful for performance and type safety.
Subrouter
tRPC use nested router to define subrouter, while Elysia use .use() method to define a subrouter.
::: code-group
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
const subRouter = t.router({
user: t.procedure.query(() => 'Hello User')
})
const appRouter = t.router({
api: subRouter
})
:::
tRPC use nested router to define subrouter
::: code-group
import { Elysia } from 'elysia'
const subRouter = new Elysia()
.get('/user', 'Hello User')
const app = new Elysia()
.use(subRouter)
:::
Elysia use a
.use()method to define a subrouter
While you can inline the subrouter in tRPC, Elysia use .use() method to define a subrouter.
Validation
Both support Standard Schema for validation. Allowing you to use various validation library like Zod, Yup, Valibot, and so on.
::: code-group
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
const appRouter = t.router({
user: t.procedure
.input(
z.object({
id: z.number(),
name: z.string()
})
)
.mutation(({ input }) => input)
// ^?
})
:::
tRPC use
inputto define validation schema
::: code-group
import { Elysia, t } from 'elysia'
const app = new Elysia()
.patch('/user/:id', ({ params, body }) => ({
params,
body
}),
{
params: t.Object({
id: t.Number()
}),
body: t.Object({
name: t.String()
})
})
import { Elysia } from 'elysia'
import { z } from 'zod'
const app = new Elysia()
.patch('/user/:id', ({ params, body }) => ({
params,
body
}),
{
params: z.object({
id: z.number()
}),
body: z.object({
name: z.string()
})
})
import { Elysia } from 'elysia'
import * as v from 'zod'
const app = new Elysia()
.patch('/user/:id', ({ params, body }) => ({
params,
body
}),
{
params: v.object({
id: v.number()
}),
body: v.object({
name: v.string()
})
})
:::
Elysia use specific property to define validation schema
Both offers type inference from schema to context automatically.
File upload
tRPC doesn't support file upload out-of-the-box and require you to use base64 string as input which is inefficient, and doesn't support mimetype validation.
While Elysia has built-in support for file upload using Web Standard API.
::: code-group
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
import { fileTypeFromBuffer } from 'file-type'
const t = initTRPC.create()
export const uploadRouter = t.router({
uploadImage: t.procedure
.input(z.base64())
.mutation(({ input }) => {
const buffer = Buffer.from(input, 'base64')
const type = await fileTypeFromBuffer(buffer)
if (!type || !type.mime.startsWith('image/'))
throw new TRPCError({
code: 'UNPROCESSABLE_CONTENT',
message: 'Invalid file type',
})
return input
})
})
:::
tRPC
::: code-group
import { Elysia, t } from 'elysia'
const app = new Elysia()
.post('/upload', ({ body }) => body.file, {
body: t.Object({
file: t.File({
type: 'image'
})
})
})
:::
Elysia handle file, and mimetype validation declaratively
As doesn't validate mimetype out-of-the-box, you need to use a third-party library like file-type to validate an actual type.
Middleware
tRPC middleware use a single queue-based order with next similar to Express, while Elysia give you a more granular control using an event-based lifecycle.
Elysia's Life Cycle event can be illustrated as the following.
Click on image to enlarge
While tRPC has a single flow for request pipeline in order, Elysia can intercept each event in a request pipeline.
::: code-group
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
const log = t.middleware(async ({ ctx, next }) => {
console.log('Request started')
const result = await next()
console.log('Request ended')
return result
})
const appRouter = t.router({
hello: log
.procedure
.query(() => 'Hello World')
})
:::
tRPC use a single middleware queue defined as a procedure
::: code-group
import { Elysia } from 'elysia'
const app = new Elysia()
// Global middleware
.onRequest(({ method, path }) => {
console.log(`${method} ${path}`)
})
// Route-specific middleware
.get('/protected', () => 'protected', {
beforeHandle({ status, headers }) {
if (!headers.authorizaton)
return status(401)
}
})
:::
Elysia use a specific event interceptor for each point in the request pipeline
While tRPC has a next function to call the next middleware in the queue, Elysia use specific event interceptor for each point in the request pipeline.
Sounds type safety
Elysia is designed to be sounds type safety.
For example, you can customize context in a type safe manner using derive and resolve while tRPC offers one by using context by type case which is doesn't ensure 100% type safety, making it unsounds.
::: code-group
import { initTRPC } from '@trpc/server'
const t = initTRPC.context<{
version: number
token: string
}>().create()
const appRouter = t.router({
version: t.procedure.query(({ ctx: { version } }) => version),
// ^?
token: t.procedure.query(({ ctx: { token, version } }) => {
version
// ^?
return token
// ^?
})
})
:::
tRPC use
contextto extend context but doesn't have sounds type safety
::: code-group
import { Elysia } from 'elysia'
const app = new Elysia()
.decorate('version', 2)
.get('/version', ({ version }) => version)
.resolve(({ status, headers: { authorization } }) => {
if(!authorization?.startsWith('Bearer '))
return status(401)
return {
token: authorization.split(' ')[1]
}
})
.get('/token', ({ token, version }) => {
version
// ^?
return token
// ^?
})
:::
Elysia use a specific event interceptor for each point in the request pipeline
Middleware parameter
Both support custom middleware, but Elysia use macro to pass custom argument to custom middleware while tRPC use higher-order-function which is not type safe.
::: code-group
import { initTRPC, TRPCError } from '@trpc/server'
const t = initTRPC.create()
const findUser = (authorization?: string) => {
return {
name: 'Jane Doe',
role: 'admin' as const
}
}
const role = (role: 'user' | 'admin') =>
t.middleware(({ next, input }) => {
const user = findUser(input as string)
// ^?
if(user.role !== role)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
})
return next({
ctx: {
user
}
})
})
const appRouter = t.router({
token: t.procedure
.use(role('admin'))
.query(({ ctx: { user } }) => user)
// ^?
})
// ---cut-after---
// Unused
:::
tRPC use higher-order-function to pass custom argument to custom middleware
::: code-group
const findUser = (authorization?: string) => {
return {
name: 'Jane Doe',
role: 'admin' as const
}
}
// ---cut---
import { Elysia } from 'elysia'
const app = new Elysia()
.macro({
role: (role: 'user' | 'admin') => ({
resolve({ status, headers: { authorization } }) {
const user = findUser(authorization)
if(user.role !== role)
return status(401)
return {
user
}
}
})
})
.get('/token', ({ user }) => user, {
// ^?
role: 'admin'
})
:::
Elysia use macro to pass custom argument to custom middleware
Error handling
tRPC use middleware-like to handle error, while Elysia provide custom error with type safety, and error interceptor for both global and route specific error handler.
::: code-group
import { initTRPC, TRPCError } from '@trpc/server'
const t = initTRPC.create()
class CustomError extends Error {
constructor(message: string) {
super(message)
this.name = 'CustomError'
}
}
const appRouter = t.router()
.middleware(async ({ next }) => {
try {
return await next()
} catch (error) {
console.log(error)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: error.message
})
}
})
.query('error', () => {
throw new CustomError('oh uh')
})
:::
tRPC use middleware-like to handle error
::: code-group
import { Elysia } from 'elysia'
class CustomError extends Error {
// Optional: custom HTTP status code
status = 500
constructor(message: string) {
super(message)
this.name = 'CustomError'
}
// Optional: what should be sent to the client
toResponse() {
return {
message: "If you're seeing this, our dev forgot to handle this error",
error: this
}
}
}
const app = new Elysia()
// Optional: register custom error class
.error({
CUSTOM: CustomError,
})
// Global error handler
.onError(({ error, code }) => {
if(code === 'CUSTOM')
// ^?
return {
message: 'Something went wrong!',
error
}
})
.get('/error', () => {
throw new CustomError('oh uh')
}, {
// Optional: route specific error handler
error({ error }) {
return {
message: 'Only for this route!',
error
}
}
})
:::
Elysia provide more granular control over error handling, and scoping mechanism
While tRPC offers error handling using middleware-like, Elysia provide:
- Both global and route specific error handler
- Shorthand for mapping HTTP status and
toResponsefor mapping error to a response - Provide a custom error code for each error
The error code is useful for logging and debugging, and is important when differentiating between different error types extending the same class.
Elysia provides all of this with type safety while tRPC doesn't.
Encapsulation
tRPC encapsulate side-effect of a by procedure or router making it always isolated, while Elysia give you a control over side-effect of a plugin via explicit scoping mechanism, and order-of-code.
::: code-group
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
const subRouter = t.router()
.middleware(({ ctx, next }) => {
if(!ctx.headers.authorization?.startsWith('Bearer '))
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Unauthorized',
})
return next()
})
const appRouter = t.router({
// doesn't have side-effect from subRouter
hello: t.procedure.query(() => 'Hello World'),
api: subRouter
.mutation('side-effect', () => 'hi')
})
:::
tRPC encapsulate side-effect of a plugin into the procedure or router
::: code-group
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 encapsulate side-effect of a plugin unless explicitly stated
Both has a encapsulate mechanism of a plugin to prevent side-effect.
However, Elysia can explicitly stated which plugin should have side-effect by declaring a scoped while Fastify always encapsulate it.
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('scoped') // [!code ++]
const app = new Elysia()
.get('/', 'Hello World')
.use(subRouter)
// [!code ++]
// now have side-effect from subRouter
.get('/side-effect', () => 'hi')
Elysia offers 3 type of scoping mechanism:
- local - Apply to current instance only, no side-effect (default)
- scoped - Scoped side-effect to the parent instance but not beyond
- global - Affects every instances
OpenAPI
tRPC doesn't offers OpenAPI first party, and relying on third-party library like trpc-to-openapi which is not a streamlined solution.
While Elysia has built-in support for OpenAPI using @elysiajs/openapi from a single line of code.
::: code-group
import { initTRPC } from '@trpc/server'
import { createHTTPServer } from '@trpc/server/adapters/standalone'
import { OpenApiMeta } from 'trpc-to-openapi';
const t = initTRPC.meta<OpenApiMeta>().create()
const appRouter = t.router({
user: t.procedure
.meta({
openapi: {
method: 'post',
path: '/users',
tags: ['User'],
summary: 'Create user',
}
})
.input(
t.array(
t.object({
name: t.string(),
age: t.number()
})
)
)
.output(
t.array(
t.object({
name: t.string(),
age: t.number()
})
)
)
.mutation(({ input }) => input)
})
export const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'tRPC OpenAPI',
version: '1.0.0',
baseUrl: 'http://localhost:3000'
})
:::
tRPC rely on third-party library to generate OpenAPI spec
::: code-group
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi' // [!code ++]
const app = new Elysia()
.use(openapi()) // [!code ++]
.model({
user: t.Array(
t.Object({
name: t.String(),
age: t.Number()
})
)
})
.post('/users', ({ body }) => body, {
// ^?
body: 'user',
response: {
201: 'user'
},
detail: {
summary: 'Create user'
}
})
:::
Elysia seamlessly integrate the specification into the schema
tRPC rely on third-party library to generate OpenAPI spec, and MUST require you to define a correct path name and HTTP method in the metadata which is force you to be consistently aware of how you place a router, and procedure.
While Elysia use schema you provide to generate the OpenAPI specification, and validate the request/response, and infer type automatically all from a single source of truth.
Elysia also appends the schema registered in model to the OpenAPI spec, allowing you to reference the model in a dedicated section in Swagger or Scalar UI while this is missing on tRPC inline the schema to the route.
Testing
Elysia use Web Standard API to handle request and response while tRPC require a lot of ceremony to run the request using createCallerFactory.
::: code-group
import { describe, it, expect } from 'vitest'
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
const publicProcedure = t.procedure
const { createCallerFactory, router } = t
const appRouter = router({
post: router({
add: publicProcedure
.input(
z.object({
title: z.string().min(2)
})
)
.mutation(({ input }) => input)
})
})
const createCaller = createCallerFactory(appRouter)
const caller = createCaller({})
describe('GET /', () => {
it('should return Hello World', async () => {
const newPost = await caller.post.add({
title: '74 Itoki Hana'
})
expect(newPost).toEqual({
title: '74 Itoki Hana'
})
})
})
:::
tRPC require
createCallerFactory, and a lot of ceremony to run the request
::: code-group
import { Elysia, t } from 'elysia'
import { describe, it, expect } from 'vitest'
const app = new Elysia()
.post('/add', ({ body }) => body, {
body: t.Object({
title: t.String({ minLength: 2 })
})
})
describe('GET /', () => {
it('should return Hello World', async () => {
const res = await app.handle(
new Request('http://localhost/add', {
method: 'POST',
body: JSON.stringify({ title: '74 Itoki Hana' }),
headers: {
'Content-Type': 'application/json'
}
})
)
expect(res.status).toBe(200)
expect(await res.res()).toEqual({
title: '74 Itoki Hana'
})
})
})
:::
Elysia use Web Standard API to handle request and response
Alternatively, Elysia also offers a helper library called Eden for End-to-end type safety which is similar to tRPC.createCallerFactory, allowing us to test with auto-completion, and full type safety like tRPC without the ceremony.
import { Elysia } from 'elysia'
import { treaty } from '@elysiajs/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
Both offers end-to-end type safety for client-server communication.
::: code-group
import { initTRPC } from '@trpc/server'
import { createHTTPServer } from '@trpc/server/adapters/standalone'
import { z } from 'zod'
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
const t = initTRPC.create()
const appRouter = t.router({
mirror: t.procedure
.input(
z.object({
message: z.string()
})
)
.output(
z.object({
message: z.string()
})
)
.mutation(({ input }) => input)
})
const server = createHTTPServer({
router: appRouter
})
server.listen(3000)
const client = createTRPCProxyClient<typeof appRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000'
})
]
})
const { message } = await client.mirror.mutate({
message: 'Hello World'
})
message
// ^?
// ---cut-after---
console.log('ok')
:::
tRPC use
createTRPCProxyClientto create a client with end-to-end type safety
::: code-group
import { Elysia, t } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia()
.post('/mirror', ({ body }) => body, {
body: t.Object({
message: t.String()
})
})
const api = treaty(app)
const { data, error } = await api.mirror.post({
message: 'Hello World'
})
if(error)
throw error
// ^?
console.log(data)
// ^?
// ---cut-after---
console.log('ok')
:::
Elysia use
treatyto run the request, and offers end-to-end type safety
While both offers end-to-end type safety, tRPC only handle happy path where the request is successful, and doesn't have a type soundness of error handling, making it unsound.
If type soundness is important for you, then Elysia is the right choice.
While tRPC is a great framework for building type-safe APIs, it has its limitations in terms of RESTful compliance, and type soundness.
Elysia is designed to be ergonomic and developer-friendly with a focus on developer experience, and type soundness complying with RESTful, OpenAPI, and WinterTC Standard making it a better fit for building a universal API.
Alternatively, if you are coming from a different framework, you can check out: