Untitled Page

ID: 2280https://elysiajs.com/migrate/from-trpc.md
Source

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 input for 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 input to 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. 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.

::: 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 context to 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:

  1. Both global and route specific error handler
  2. Shorthand for mapping HTTP status and toResponse for mapping error to a response
  3. 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:

  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 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 createTRPCProxyClient to 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 treaty to 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: