Type safety everywhere
TypeScript is 10 years old now! Even if it seems like a long time (especially in a web dev world) its adoption is thriving like never before. According to the Stack Overflow Developer Survey, TypeScript is one of the most loved languages.
The love for TypeScript has caused a "type-safe movement" in the world of JavaScript. More and more libraries are designed with types in mind. One of the core examples is schema validation library
Zod. The API is almost the same as in the industry standard (not anymore I think) library Yup. There is one huge difference tho. Zod is fully type-safe which means that you can infer the types based on your validation schema. If you are using TypeScript you can have a single source of truth which is your Zod schema and TypesScript types are created based on that schema. It's just one example of a library that is built with TypeScript as a core utility.
Why would you need tRPC?
Can type safety be introduced to network requests and API calls? Well, yes! But first things first. REST is the industry standard for many years and is not going anywhere. It has many benefits like full control over your endpoints, well-documented standards, and most developers are familiar with REST APIs. In most cases REST will be the perfect form of speaking to APIs but other standards like GraphQL can solve specific problems which are not possible in the classic REST approach. But what about type safety? Of course, you can write your interfaces and type your business logic on a backend and a frontend but the biggest downside to this is that you don't have a single source of truth. If some field would change its type on a backend, you need to reflect those changes on the frontend as well. It can easily become cumbersome in larger projects. Any ways to solve this particular issue? Yes! It's tRPC!!
tRPC stands for TypeScript Remote Procedure Call and it's just a definition of executing some procedures on different address spaces. It's a standard like REST or GraphQL which describes the way of talking to APIs. It has some amazing benefits like:
- Automatic type-safety
- Snappy DX
- Is framework agnostic
- Amazing autocompletion
- Light bundle size
Keep in mind that tRPC will only be beneficial when you are using TypeScript inside a full-stack project like Nuxt, Next, Remix, SvelteKit, etc. In this article, we will build an example API using Nuxt.
tRPC + Nuxt API routes = Type safety 💚
Ok, let's start by creating a new Nuxt 3 project.
npx nuxi init nuxt-trpc
navigate to the project directory, then install the dependencies:
npm install
Ok, since we have Nuxt project ready, we need to install the following packages:
- @trpc/server - tRPC package used on a backend
- @trpc/client - tRPC package used on a frontend
- trpc-nuxt - tRPC adapter for Nuxt
- zod - for schema validation
We can install all packages at once:
npm install @trpc/server @trpc/client trpc-nuxt zod
Ok now that we have all packages installed, we are ready to go!
let's run the project:
npm run dev
First, let's create a file inside server/trpc directory server/trpc/trpc.ts
and fill the content of the file with the following code:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
// Base router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
This file is fairly simple, we are importing initTRPC
which is a builder for our tRPC handler, and creating an instance called t
with initTRPC.create();
. Next, we are exporting the router and public procedure as our own variables. This is mainly to avoid conflicts in global namespace for example with i18n libraries which sometimes also use t
keyword. Since we'll be only importing the router and public procedure from this file we can move on to the next parts.
Let's create the main heart of our API which is our router!
Please make a new file inside server/trpc/routers called index.ts server/trpc/routers/index.ts
and paste the following code:
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
export const appRouter = router({
getBook: publicProcedure
.input(
z.object({
id: z.number()
})
)
.query(({ input }) => {
const id = input?.id;
const books = [
{
id: 1,
title: 'Harry Potter',
author: 'J. K. Rowling'
},
{
id: 2,
title: 'Lord of The Rings',
author: 'J.R.R. Tolkien'
}
];
return books.find((book) => {
return book.id === id;
});
}),
addBook: publicProcedure
.input(z.object({
title: z.string(),
author: z.string()
}))
.mutation((req) => {
const newBook = {
title: req.input.title,
author: req.input.author
};
return newBook;
})
});
export type AppRouter = typeof appRouter
Let's take a closer look at what is happening here:
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
We are importing z
function from zod library which will provide schema validation. On the second line, we are importing our publicProdedure and router from the previously created file.
The next lines are about appRouter config. We have to initialize the router using a constructor and export the router because we will need it later in our API handler.
export const appRouter = router({
//config
});
Now we will create our first procedure called getBooks
and it will be a simple endpoint responsible for fetching a piece of information about a book with a specific id.
getBook: publicProcedure
.input(
z.object({
id: z.number()
})
)
.query(({ input }) => {
const id = input?.id;
const books = [
{
id: 1,
title: 'Harry Potter',
author: 'J. K. Rowling'
},
{
id: 2,
title: 'Lord of The Rings',
author: 'J.R.R. Tolkien'
}
];
return books.find((book) => {
return book.id === id;
});
}),
Inside the router object config, we are creating a new route called getBook
by calling publicProcedure
and chaining it with input
and query
functions. Input is responsible for declaring the shape of our request parameters:
.input(
z.object({
id: z.number()
})
)
As you can see in the code above, we are using zod to make the structure of API parameters. This particular endpoint expects to receive an object like this one:
{
id: 4
}
Let's move to the query part.
.query(({ input }) => {
const id = input?.id;
const books = [
{
id: 1,
title: 'Harry Potter',
author: 'J. K. Rowling'
},
{
id: 2,
title: 'Lord of The Rings',
author: 'J.R.R. Tolkien'
}
];
return books.find((book) => {
return book.id === id;
});
})
The query is responsible for the logic of our query and returning the response. Firstly we have to read the id from the input object. Let's create the new variable holding this id:
const id = input?.id;
Now that we have our ID we can write our custom logic and return what we want. In this example code, we are searching for the book with provided ID through the static array and returning the result.
const books = [
{
id: 1,
title: 'Harry Potter',
author: 'J. K. Rowling'
},
{
id: 2,
title: 'Lord of The Rings',
author: 'J.R.R. Tolkien'
}
];
return books.find((book) => {
return book.id === id;
});
Great, we have our first endpoint which is a GET request! But what about POST requests? The steps will be almost the same but instead of query
function we will use mutation
:
addBook: publicProcedure
.input(z.object({
title: z.string(),
author: z.string()
}))
.mutation((req) => {
const newBook = {
title: req.input.title,
author: req.input.author
};
return newBook;
})
We have created an addBook
mutation that expects an object like this one:
{
title: 'some book title',
author: 'some author name '
}
Inside mutation
, we are writing our business logic for this endpoint and returning the response. Here we are just passing back the book object for demo purposes.
Last but not least we have to export the types of our router which is the core part of tRPC!
export type AppRouter = typeof appRouter
We will use this type later on our client.
Great we have our router ready!
Our API will be based on Nuxt server routes. We need to find a way to aggregate all endpoints starting with api/trpc. An example URL can look like this: http://localhost:3000/api/trpc/getBook
.
Let's create a new file inside the server directory /server/api/trpc/[trpc].ts
. Creating a structure like this one and using wildcard []
syntax will cause all endpoints with api/trpc/* pattern to land inside this file.
Please take a look at the content of this file:
import { createNuxtApiHandler } from 'trpc-nuxt';
import { appRouter } from '@/server/trpc/routers';
// export API handler
export default createNuxtApiHandler({
router: appRouter,
createContext: () => ({})
});
Inside this file, we have a Nuxt API handler (special package from trpc-nuxt) and we are passing the previously created appRouter
with the routing of our API. Every request call will land on this file and make decisions based on routing.
Now we can move to the frontend. We need a way to provide a tRPC client to every Vue component in our app. The best way to achieve it would be using plugins. Let's create a new file inside the plugins directory: plugins/client.ts
.
Your file should look like this:
import { httpBatchLink, createTRPCProxyClient } from '@trpc/client';
import type { AppRouter } from '@/server/trpc/routers';
export default defineNuxtPlugin(() => {
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc'
})
]
});
return {
provide: {
client
}
};
});
In this file, we are defining our Nuxt plugin which will expose the tRPC client under $client
variable. You can notice that we are passing AppRouter
type to createTRPCProxyClient
which is the type imported from our router. Because of that, we will have a fully typed client! The only thing you have to set is the URL of the API. We can achieve it with links config:
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc'
})
]
In the end, we have to return and provide the client:
return {
provide: {
client
}
};
We have a plugin created so we are ready to use our tRPC client inside one of our Vue components.
We can test it inside app.vue
file:
<script setup lang="ts">
const { $client } = useNuxtApp();
// for client side request
const data = await $client.getBook.query({ id: 1 });
// for server side request
// const { data } = await useAsyncData(() => $client.getBook.query({ id: 1 }));
</script>
<template>
<div>
<h1>{{ data?.title }}</h1>
<h2>{{ data?.author }}</h2>
</div>
</template>
As you can see it's really simple. The code above shows two methods of fetching the data:
1) Client side:
const data = await $client.getBook.query({ id: 1 });
1) Server side by using nuxt built-in useAsyncData
composable:
const { data } = await useAsyncData(() => $client.getBook.query({ id: 1 }));
Let's take a look at the main power of tRPC:
Type safety:
When you will try to pass the param with the wrong type, for example, string instead of a number:
const data = await $client.getBook.query({
id: 'someID'
});
- Refactoring tools:
When you want to change the name of one of the procedures, you can use the VSCode Rename Symbol option and the change will be reflected both on the server and client! Let's try changing
getBook
togetBookById
.
Final words
tRPC is a great library/standard to write your APIs and provide full type safety between your backend and frontend code. It's not only about types but also about amazing developer experience and autocomplete. Refactoring and code navigation also works well in the context of tRPC, so I would consider using it in a full-stack project with the help of frameworks like Nuxt.
Check out the source code:
Source code
Top comments (4)
Thanks for the amazing + complete tutorial. 💚
tRPC seems quite promising in comparison to GraphQL and would be heavily adopted in the upcoming months/years I think. 👍🏻
Thank you Konstantin! I'm glad you liked the article. Yeah, I think we will definitely see more adoption of tRPC soon :)
There is also quite a lot of people pushing it forward and it's also getting quite more visibility, so it will probably help. 👍🏻
Also, everybody loves TS lately!
this is a great starter to nuxt trpc, but i do suggest updating this tutorial, as i currently followed your tutorial, and it is popping out errors on me.. im already currently using nuxt 3.2.2, and this tutorial isn't working anymore.