Skip to content
Our Sponsors
Open in Anthropic

From tRPC to Elysia

This guide is for tRPC users who want to see the 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 a 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.

The 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 uses a syntax similar to Express, and Hono like app.get() and app.post() methods to define routes and similar path parameters syntax.

While tRPC uses a nested router approach to define routes.

ts
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 uses nested router and procedure to define routes

ts
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 HTTP method, and path parameters to define routes

While tRPC use proprietary abstraction over RESTful API with procedure and router, Elysia uses 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 uses HTTP method like get, post, put, delete and so on.

tRPC doesn't have a concept of HTTP property like query, headers, status code, and so on, only input and output.

ts
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 uses single input for all properties

ts
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 uses specific property for each HTTP property

Elysia uses static code analysis to determine what to parse, and only parses 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.

ts
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 uses nested router to define subrouter

ts
import { Elysia } from 'elysia'

const subRouter = new Elysia()
	.get('/user', 'Hello User')

const app = new Elysia()
	.use(subRouter)

Elysia uses 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.

ts
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 input to define validation schema

ts
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
()
}) })
ts
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
()
}) })
ts
import { 
Elysia
} from 'elysia'
import * as
v
from 'valibot'
const
app
= new
Elysia
()
.
patch
('/user/:id', ({
params
,
body
}) => ({
params
,
body
}), {
params
:
v
.
object
({
id
:
v
.
number
()
}),
body
:
v
.
object
({
name
:
v
.
string
()
}) })

Elysia uses specific property to define validation schema

Both offer type inference from schema to context automatically.

File upload

tRPC doesn't support file upload out-of-the-box and requires 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.

ts
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

ts
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 tRPC 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 uses a single queue-based order with next similar to Express, while Elysia gives you more granular control using an event-based lifecycle.

Elysia's Life Cycle event can be illustrated as the following. Elysia Life Cycle Graph

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.

ts
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 uses a single middleware queue defined as a procedure

ts
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 uses 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 uses specific event interceptor for each point in the request pipeline.

Sound type safety

Elysia is designed to provide sound 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 casting which doesn't ensure 100% type safety, making it unsound.

ts
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 uses context to extend context but doesn't have sound type safety

ts
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 uses 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.

ts
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
)
})

tRPC use higher-order-function to pass custom argument to custom middleware

ts
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 uses macro to pass custom arguments to custom middleware

Error handling

tRPC uses middleware-like to handle error, while Elysia provides custom error with type safety, and error interceptor for both global and route-specific error handler.

ts
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 uses middleware-like to handle error

ts
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 provides more granular control over error handling, and scoping mechanism

While tRPC offers error handling using middleware-like, Elysia provides:

  1. Both global and route-specific error handler
  2. Shorthand for mapping HTTP status and toResponse for mapping error to a response
  3. Provides 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 encapsulates side-effects of a procedure or router making it always isolated, while Elysia gives you control over side-effects of a plugin via explicit scoping mechanism, and order-of-code.

ts
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 encapsulates side-effects of a plugin into the procedure or router

ts
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 a scoped while tRPC always encapsulates it.

ts
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') 

const app = new Elysia()
    .get('/', 'Hello World')
    .use(subRouter)
    // now have side-effect from subRouter
    .get('/side-effect', () => 'hi')

Elysia offers 3 types of scoping mechanism:

  1. local - Apply to current instance only, no side-effect (default)
  2. scoped - Scoped side-effect to the parent instance but not beyond
  3. global - Affects every instance

OpenAPI

tRPC doesn't offer OpenAPI first-party, and relies 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.

ts
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 relies on third-party library to generate OpenAPI spec

ts
import { 
Elysia
,
t
} from 'elysia'
import {
openapi
} from '@elysiajs/openapi'
const
app
= new
Elysia
()
.
use
(
openapi
())
.
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 integrates the specification into the schema

tRPC relies 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 forces you to be consistently aware of how you place a router, and procedure.

While Elysia uses schema you provide to generate the OpenAPI specification, and validates the request/response, and infers types 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 uses Web Standard API to handle request and response while tRPC requires a lot of ceremony to run the request using createCallerFactory.

ts
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 requires createCallerFactory, and a lot of ceremony to run the request

ts
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 uses 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.

ts
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 offer end-to-end type safety for client-server communication.

ts
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

tRPC uses createTRPCProxyClient to create a client with end-to-end type safety

ts
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
)

Elysia uses treaty to run the request, and offers end-to-end type safety

While both offer end-to-end type safety, tRPC only handles 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 WinterCG standards, making it a better fit for building a universal API.

Alternatively, if you are coming from a different framework, you can check out: