Getting started with tRPC v10 by building a todo app - Backend
Getting started with tRPC v10 by building a todo app - Backend
By Zjerlondy Ferero
10 min read
There's nothing quite like the feeling of finally getting your front-end and back-end types to match up. After hours of slacking, emailing, and going through documentation, you finally have it! But that feeling doesn't last long, because you realize the back-end had some type changes, which broke your front-end again! Well, here is where tRPC comes into play.
- Authors
- Name
- Zjerlondy Ferero
- linkedinZjerlondy Ferero
- Github
- githublondyf
tRPC is a lightweight library that allows you to create fully typesafe APIs without schemas or code generation. It provides end-to-end typing, which means that types can be shared between the server and client(s). This results in fewer bugs and less boilerplate code.
In this two-part series, we'll be building a todo app using tRPC. In the first part, we'll start by creating a backend API using tRPC, and then in the second part, we'll create a (React Native) frontend UI that consumes the API. By the end of this series, you'll have a fully functioning todo app!
Note: at the time this article was written, tRPC v10 was still in beta; therefore, some of the provided code examples might not work anymore.
Prerequisites
- (Minimum) Node v8 installed
- React (Native) development environment
- Basic React (Native) / Typescript knowledge
We won't be focusing on installing and setting up Typescript in this article; therefore, I've created a Github repo with some basic Typescript boilerplate to get us started right away!
Setting up our Express/tRPC backend
We will use tRPC in conjunction with ExpressJS in this article. tRPC is frequently used in conjunction with Next.js, but for this article, I chose ExpressJS to demonstrate tRPC's power in conjunction with ExpressJS. tRPC has a nice ExpressJS adapter that handles some of the tRPC magic for us, thus making setting up our tRPC backend very easy. Even though we use ExpressJS, tRPC can be used in combination with any (Node) backend that supports Typescript.
Installing dependencies
Let's start by using the terminal to navigate to the server
directory in our project directory, and install the following dependencies:
cd server && npm install @trpc/server@next zod
As you can see, we install the @trpc/server
package, but also a package named Zod
. Zod is a library that makes input validation very simple by using schema-based validation. With Zod, tRPC can validate incoming requests against a predefined schema, keeping our handler function clean and free of any unnecessary validation checks. tRPC has out-of-the-box support for different schema validation tools such as Yup and Superstruct.
Initializing tRPC and creating our first router
Let's start by creating a new file called trpc.ts
in the server/src/
of our project folder. In the file, add the following code:
import { initTRPC } from '@trpc/server'
export const t = initTRPC.create()
All this file is responsible for is initializing and exporting a tRPC instance.
Now, let's start by creating, arguably, the most important piece of our back-end, the router. Begin by creating a new file called todo.ts
in the server/src/routers/
folder of our project folder. In the todo.ts
add the following code:
// Import our tRPC instance
import { t } from '../trpc'
// Initalize an empty array where we will be storing our todo's.
// For now, we will use the type `any[]`, but this will be changed later on.
let todos: any[] = []
// Create our todo router, and add a query procedure (equivalent of a REST Get request) called `all`,
// which will be responsible for returning all the stored todo's
export const todoRouter = t.router({
all: t.procedure.query(() => {
return todos
}),
})
We create a tRPC router by calling the router()
method and passing an object containing the different endpoints and their procedures as an argument. tRPC knows two procedures:
- Query: Equivalent to a REST
Get
call - Mutation: Used for creating, updating, and deleting data. Equivalent to the REST
POST
,PATCH
,PUT
, andDELETE
calls.
In the code snippet above, we are first importing our tRPC instance, and after, that we are creating a todos array which will be used to store our todos. After creating our todo's array, we create our todoRouter
which currently has one query
procedure called all
, which will return all our stored todo's.
Now, let's create a new file, index.ts
in the same server/src/routers/
folder of our project, and add the following code:
// Import our tRPC instance
import { t } from '../trpc'
// Import our todo Router
import { todoRouter } from './todo'
// Create an appRouter which will be used to tie together all our different routers
// In our case, we will only have one router, our todo router. This todo router will be exported under the namespace `todo`.
export const appRouter = t.router({
todo: todoRouter,
})
// Export only the **type** of a router to avoid importing server code on the client
export type AppRouter = typeof appRouter
As you can see, we start again by importing our tRPC instance, followed by importing our todoRouter
. After that, we create a new appRouter
which will be used to tie together all our different routers. In our case, this will only be our todoRouter
, which is exported under the namespace todo
.
Namespaces are used to organize our routes. To consume our API in the front-end, you will make use of the namespaces to identify the correct router. For instance, if you wanted to query the todo router, you would write: todo.
followed by the procedure name, in our case: todo.all
.
Lastly, we export the type AppRouter
, so that we can use it later on in the front-end.
Creating our server
Now that we have created our router, we would want to have a way to make calls to that router, so let's start by creating a Express server. In the server/src
folder of our project, we will be creating a new file called index.ts
which contains the following code:
// Import the tRPC Express Adatper
import * as trpcExpress from '@trpc/server/adapters/express'
// Import Express
import express from 'express'
// Import our App Router
import appRouter from './routers'
// Initialize Express
const app = express()
// Tell Express to parse incoming requests using JSON
app.use(express.json())
// Tell Express to let the tRPC adapter handle all incoming requests to `/trpc`
app.use(
'/trpc',
trpcExpress.createExpressMiddleware({
router: appRouter,
})
)
// Start the server under the port 3000
app.listen(3000)
If you are familiar with Express, the code above is pretty self-explanatory. First, we start by importing the tRPC Express Adapter, Express and finally our appRouter
. After that, we initialize Express, and tell Express to use the express.json()
middleware to parse incoming requests to JSON. After that, we instruct Express to route all incoming calls to /trpc through a middleware exported from the trpcExpress.createExpressMiddleware() method. This method takes an option object as an argument. In our case, we only pass our appRouter
as an option. So, all incoming requests to /trpc
will be handled by our appRouter
.
Testing our first endpoint
Now that we have created our server and a simple route, it's time to test! We can do this by simply starting our server, hitting the API, and checking the response.
Start by navigating to the project folder in the terminal and running npm run dev
. This will start the server on port 3000
. Now, open a browser and navigate to http://localhost:3000/trpc/todo.all
, which will result in the following JSON response:
{
"result": {
"data": []
}
}
As you can see, we are getting result from the server, but the issue is, the data array is empty. Let's fix that! Let's jump back to our todo.ts
file in the server/src/routers
directory. If you look at the following line const todos: any[] = [];
, you will see that we are initializing todos
as an empty array. Let's change that by adding some data: const todos: any[] = ["todo1", "todo2", "todo3"];
. Now, go back to your browser and refresh the page. If everything went well, you would see the following response:
{
"result": {
"data": ["todo1", "todo2", "todo3"]
}
}
We have concluded that everything is working fine, but manually altering the todos array is not the way to go, so let's make an API that allows us to create todos!
Creating our first mutation
As mentioned previously, tRPC knows two types of procedures: a query procedure and a mutation procedure. When creating our todo.all
procedure, which is responsible for getting data, we used the query procedure.
Let's start by heading back to our todo.ts
file in our routers/
directory, and adding a new procedure to our todo router:
export const todoRouter = t.router({
all: t.procedure.query(() => {
return todos
}),
add: t.procedure.mutation(({ input }) => {
todos.push(input)
return todos
}),
})
As you can see, we added a new mutation procedure called add, and all it's responsible for, is pushing the received input to the todos
array and after that, returning the new altered todos array.
Testing our first mutation
To test if this is working, let's open Postman
(or any other API testing tool), and simply hit our new todo.add
using the following URL: http://localhost:3000/trpc/todo.add. The request body can simply be:
{
"title": "our first todo"
}
After hitting our new todo.add
endpoint, you should've gotten the following response;
{
"result": {
"data": [null]
}
}
As you can see, it's adding null
to our data array. This is happening due to us not specifying to tRPC what the body of our incoming request will look like, and therefore tRPC fails to parse the incoming request into usable data. There are several ways we can fix this, but we will fix this by simply declaring a specific input format for our todo.add
procedure. Let's jump back to our todo router file, and modify our code to match the following code:
// Import our input validation tool
import { z } from 'zod'
// Import our tRPC instance
import { t } from '../trpc'
// Initalize an empty array where we will be storing our todo's.
// For now, we will use the type 'any[]', but this will be changed later on.
let todos: any[] = []
// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
all: t.procedure.query(() => {
return todos
}),
add: t.procedure
.input(
z.object({
title: z.string(),
})
)
.mutation(({ input }) => {
todos.push(input)
return todos
}),
})
If you look closely at the add
procedure, you will notice we added a new method called input()
. This input()
method takes an input validation schema that defines what our incoming request body should look like. tRPC supports a handful of input validators, but we have decided to use Zod
. If you look closely, you can see that all we are doing is telling tRPC that our request body should exist of an object containing a title that is a string. Let's jump back to Postman and hit our todo.add
API again to test this out.
As you can see, the data array is now correctly populated:
{
"result": {
"data": [
{
"title": "our first todo"
}
]
}
}
Now let's extend our request body object by adding the extra fields: id
and completed
. The id
field will be used to uniquely identify a todo, and the completed
field will be used to mark a todo as completed. To keep our todoRouter
function clean and simple, I will be moving the add todo request body validation schema to a separate file. Let's create a new folder, models
, in our server's src/
directory. This folder will contain one file called todo.ts
. This is where we will define our add todo validation schema.
/* server/src/models/todo.ts */
import { z } from "zod";
// Helper function to generate random id's for our todo's
const generateRandomId = () => {
return Math.floor(Math.random() * 10000 + 1);
};
// Our todo Schema
export const todoSchema = z.object({
id: z.number().default(generateRandomId),
title: z.string()
completed: z.boolean().default(false),
});
// Create a new Todo type using our todo schema
export type Todo = z.infer<typeof todoSchema>;
In the code snippet above, we are leveraging Zod to create a todoSchema
. To get a detailed explanation of Zod, I recommend you look at their documentation.
Take note that we are using Zod's z.infer
function to create a new Todo
type from our todoSchema
. z.infer
is a really powerful tool you can use to extract Typescript types from Zod schemas.
Now, jump back to our todo router and modify our input()
method to use the newly created todoSchema
/* server/src/routers/todo.ts */
// Import our tRPC instance
import { t } from '../trpc'
// Import our todo Schema
import { todoSchema, Todo } from '../models/todo'
// Initalize an empty array where we will be storing our todo's.
let todos: Todo[] = []
// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
all: t.procedure.query(() => {
return todos
}),
add: t.procedure.input(todoSchema).mutation(({ input }) => {
todos.push(input)
return todos
}),
})
Let's open Postman and test our API:
Since we configured a default value when creating our todo
schema with Zod
, the id
and completed
fields will automatically be filled.
Creating our update and delete mutation
Let's continue by creating our update and delete mutations. Our delete mutation will be pretty straightforward. All it will do is take a todo's id as input and, using that id, filter out the correct todo from the todos array. Take a look at the updated todoRouter
:
// Import Zod
import { z } from 'zod'
// Import our tRPC instance
import { t } from '../trpc'
// Import our todo Schema
import { todoSchema, Todo } from '../models/todo'
// Initalize an empty array where we will be storing our todo's.
let todos: Todo[] = []
// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
all: t.procedure.query(() => {
return todos
}),
add: t.procedure.input(todoSchema).mutation(({ input }) => {
todos.push(input)
return todos
}),
delete: t.procedure.input(z.number()).mutation(({ input }) => {
const filteredTodos = todos.filter((todo) => todo.id !== input)
todos = [...filteredTodos]
return todos
}),
})
Pretty easy, right?
Let's move on to our last mutation, the update todo mutation. First, let's look at the updated code:
// Import trpc
import * as trpc from '@trpc/server'
// Import Zod
import { z } from 'zod'
// Import our tRPC instance
import { t } from '../trpc'
// Import our todo Schema
import { todoSchema, Todo } from '../models/todo'
// Initalize an empty array where we will be storing our todo's.
let todos: Todo[] = []
// Create our todo router, and add a query (equivalent of a REST Get request) procedure with the name `all`
// Which be responsible for returning all the stored todos
export const todoRouter = t.router({
all: t.procedure.query(() => {
return todos
}),
add: t.procedure.input(todoSchema).mutation(({ input }) => {
todos.push(input)
return todos
}),
delete: t.procedure.input(z.number()).mutation(({ input }) => {
const filteredTodos = todos.filter((todo) => todo.id !== input)
todos = [...filteredTodos]
return todos
}),
update: t.procedure.input(todoSchema.partial()).mutation(({ input }) => {
const index = todos.findIndex((todo) => todo.id === input.id)
const todo = todos?.[index]
if (!todo) {
throw new trpc.TRPCError({
code: 'NOT_FOUND',
message: "Given id doesn't exist",
})
}
todos[index] = {
...todo,
...input,
}
return todos[index]
}),
})
For the update mutation, we will have the request body be the same as the add todo procedure, but this time we will make all the fields optional using Zod's .partial()
helper function. Doing this makes it easy for us to partially update todos.
Another interesting thing to look at is the following code:
if (!todo) {
throw new trpc.TRPCError({
code: 'NOT_FOUND',
message: "Given id doesn't exist",
})
}
If we can't find a todo that matches the provided id, we throw a TRPCError
. The TRPCError
is a subclass that makes it possible for us to represent an error that occurred inside a procedure. The code
parameter will be mapped to an HTTP Error code. E.g. in our case, NOT_FOUND corresponds to HTTP status 404.
Testing our update and delete mutation
Now that we have implemented all our mutations, we can finally test them. Let's start by adding two new todo's using our API.
Now, let's list them using our todo.all
API
Now, let's update one of the todo's
And finally, let's delete one of them
Tada 🥳! Everything is working as it should!
Conclusion
In this article, we looked at what tRPC is by building a simple todo API. tRPC is a library that allows you to set up end-to-end typing, which leads to fewer bugs and a better development experience! tRPC can be used in conjunction with many front-end frameworks such as React, React Native, Vue, and even Svelte! For more information, take a look at the tRPC docs. The code used in this article can be found in this Github repo.
In the second part of this two-part series, we will be showcasing the power of having end-to-end typing by consuming our newly created todo API in our React Native frontend!
Thank you for reading!