Getting started with tRPC

Getting started with tRPC

ยท

11 min read

Before knowing about tRPC , let's first know about RPC .

What is RPC?

RPC is short for "Remote Procedure Call". It is a way of calling functions on one computer (the server) from another computer (the client). With traditional HTTP/REST APIs, you call a URL and get a response. With RPC, you call a function and get a response.
You can learn more about this under the "Concepts" section in tRPC's documentation .

What is tRPC?

tRPC (TypeScript Remote Procedure Call) is one implementation of RPC, designed for TypeScript monorepos. It has its own flavor, but is RPC at its heart. tRPC allows you to easily build & consume fully typesafe APIs without schemas or code generation.

I am sure this bookish language might not have made that much sense so lets build a simple project to understand it .

๐Ÿ’ก
Disclaimer: the steps given below are intended to give the results desired but since its software engineering we are talking about , errors might just pop out . So its your responsibility to google those errors and accordingly solve them and move on .

Lets build a simple project to understand tRPC

Lets initialise a project and create the initial "package.json" file using this command:

npm init -y

If you don't have typescript , install it using:

npm install typescript --save-dev

Make sure your typescript version>=4.7.0 since otherwise tRPC won't work . Now lets convert this project into a typescript project and create a tsconfig.json file using this command:

npx tsc --init

In the "tsconfig.json" file , uncomment "outDir" and assign it the value "./dist".

๐Ÿ’ก
We are going to help to take help of official docs from now onwards . But we might be using some different approach at certain places.

Lets install the initial tRPC packages :

npm install @trpc/server@next @trpc/client@next

Then we will need to create our "client" and "server" folder keeping the other files top-level.

Steps to follow for setting up backend:

There are three steps to get a create a basic tRPC backend setup:

  1. Initialise tRPC in trpc.ts file.

  2. Create a single router in index.ts file.

  3. Add an adapter to it.

Step 1:Initialising tRPC

Inside the server folder , create a new file trpc.ts and paste this code:

import { initTRPC } from '@trpc/server';

/**
 * Initialization of tRPC backend
 * Should be done only once per backend!
 */
const t = initTRPC.create();
 /**
 * we could have exported the entire t object like this:
 * export const t= ...
 * but that is what exactly we should try to avoid
 */
/**
 * Export reusable router and procedure helpers
 * that can be used throughout the router
 */
export const router = t.router;
export const publicProcedure = t.procedure;

This initialises the tRPC backend. It's good convention to do this in a separate file and export reusable helper functions instead of the entire tRPC object .

Step 2: Add a router in index.ts file

Now lets add an index.ts file in the server folder . Before adding any code , install
zod , which is required for input validation , using this command :

npm install zod

Now add this code in index.ts file:

import {z} from "zod";
import { publicProcedure, router } from "./trpc";

// this zod object is required for input validation , the input has to be of the type
// {
//     title:string,
//     description:string
// }
const todoInputType=z.object({
    title:z.string(),
    description:z.string()
})

/**
 * Here we are creating the router where a procedure is being created named "createTodo" such that
 * the input it will receive has to follow the "todoInputType" convention and we are using mutation
 * here which means that there can be a change in the data stored in DB , we pass a callback to it
 * which is going to run when the browser calls "createTodo" . In that callback, we accept the values
 * of title and description and simply return an object
 */
const appRouter=router({
    createTodo:publicProcedure
    .input(todoInputType)
    .mutation(async (opts)=>{
        const title=opts.input.title;
        const description=opts.input.description;
        //some DB stuff is supposed to occur here
        return {
            id:"1"
        }
    })
});

//here we export the type of appRouter 
export type AppRouter = typeof appRouter;

Here we used zod validation and added a router . Next , in the same file , we will be adding an adapter .

Step 3: Adding an adapter

Make the following changes and you will be good to go:

import {z} from "zod";
import { publicProcedure, router } from "./trpc";
import { createHTTPServer } from '@trpc/server/adapters/standalone';

// this zod object is required for input validation , the input has to be of the type
// {
//     title:string,
//     description:string
// }
const todoInputType=z.object({
    title:z.string(),
    description:z.string()
})

/**
 * Here we are creating the router where a procedure is being created named "createTodo" such that
 * the input it will receive has to follow the "todoInputType" convention and we are using mutation
 * here which means that there can be a change in the data stored in DB , we pass a callback to it
 * which is going to run when the browser calls "createTodo" . In that callback, we accept the values
 * of title and description and simply return an object
 */
const appRouter=router({
    createTodo:publicProcedure
    .input(todoInputType)
    .mutation(async (opts)=>{
        const title=opts.input.title;
        const description=opts.input.description;
        //some DB stuffis supposed to occur here
        return {
            id:"1"
        }
    })
});

//creating server with "appRouter" as a router
const server = createHTTPServer({
    router: appRouter,
  });

//server gets activated on port 3000
server.listen(3000);

//here we export the type of appRouter 
export type AppRouter = typeof appRouter;

Now we have added a server which would listen on port 3000 . Now lets see how we can set up the client .

๐Ÿ’ก
We are not going to use React or HTML for the client , rather we will simply keep the basic code for setting up the logic , you can always add that in your React code.

Client Setup:

Create a file index.ts in client folder and paste this code:

import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server';
//     ๐Ÿ‘† **type-only** import

// Pass AppRouter as generic here. ๐Ÿ‘‡ This lets the `trpc` object know
// what procedures are available on the server and their input/output types.
const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
    }),
  ],
});

async function main(){
    let response=await trpc.createTodo.mutate({
        title:"abc",
        description:"def"
    })

    console.log(response)
}
main();
/**
 * Here we are directly able to execute the controller
 * or the function such that we are also sending the request body instead of making call like this:
 * await axios.post("http://localhost:..../api/something",{
 * title:"...",
 * description:"..."}
 * )
 * 
 * Plus it also helps you giving types , you can hover over response
 * and see its type you can see the type of value you need to pass to mutate
 */

Its pretty straightforward. Let's run the client and the server .

Running the client and the server

Lets compile the TS code into JS code using either of the commands:

npx tsc -b

or ,

tsc -b

See which one works for you .

Next for running the server , use this command:

node dist/server/index.js

And for the client ,

node dist/client/index.js

You should be able to see the output in the client terminal being:

{id:'1'}

Lets use some advanced stuff

Context

It is mainly used to send some context(extra info) related to some data we are sending .

As our first step we would set the type of the context during initialisation by making some changes in the trpc.ts file:

import { initTRPC } from '@trpc/server';

/**
 * Initialization of tRPC backend
 * Should be done only once per backend!
 */
//added a context type 
const t = initTRPC.context<{
    username?:string}>().create();
 /**
 * we could have exported the entire t object like this:
 * export const t= ...
 * but that is what exactly we should try to avoid
 */
/**
 * Export reusable router and procedure helpers
 * that can be used throughout the router
 */
export const router = t.router;
export const publicProcedure = t.procedure;

Our next step is to create the runtime context during each request . So lets make some changes in the index.ts file .

import {z} from "zod";
import { publicProcedure, router } from "./trpc";
import { createHTTPServer } from '@trpc/server/adapters/standalone';

// this zod object is required for input validation , the input has to be of the type
// {
//     title:string,
//     description:string
// }
const todoInputType=z.object({
    title:z.string(),
    description:z.string()
})

/**
 * Here we are creating the router where a procedure is being created named "createTodo" such that
 * the input it will receive has to follow the "todoInputType" convention and we are using mutation
 * here which means that there can be a change in the data stored in DB , we pass a callback to it
 * which is going to run when the browser calls "createTodo" . In that callback, we accept the values
 * of title and description and simply return an object
 */
const appRouter=router({
    createTodo:publicProcedure
    .input(todoInputType)
    .mutation(async (opts)=>{
        const title=opts.input.title;
        const description=opts.input.description;
        //some DB stuffis supposed to occur here
        return {
            id:"1"
        }
    })
});

/**creating server with "appRouter" as a router
 * plus we are also creating a context such that before the request reaches the router 
 * the createContext is going to run where we would first check the headers (not available to router)
 * and then  return the value of the context based on the type which we gave during initialisation
 * and here its {
 * username:"abc"
 * }
 * createContext can be seen as more of a middleware for passing some extra info to the procedures
*/
const server = createHTTPServer({
    router: appRouter,
    createContext(opts){
        let authHeader=opts.req.headers["authorization"];
        console.log(authHeader);
        //...some  other stuff like you might check the header and accordingly
        //return the value for the context
        return {
            username:"abc",
        }
    }
  });

//server gets activated on port 3000
server.listen(3000);

//here we export the type of appRouter 
export type AppRouter = typeof appRouter;

Here we have created the context . Now let's try to use it in a procedure .

import {z} from "zod";
import { publicProcedure, router } from "./trpc";
import { createHTTPServer } from '@trpc/server/adapters/standalone';

// this zod object is required for input validation , the input has to be of the type
// {
//     title:string,
//     description:string
// }
const todoInputType=z.object({
    title:z.string(),
    description:z.string()
})

/**
 * Here we are creating the router where a procedure is being created named "createTodo" such that
 * the input it will receive has to follow the "todoInputType" convention and we are using mutation
 * here which means that there can be a change in the data stored in DB , we pass a callback to it
 * which is going to run when the browser calls "createTodo" . In that callback, we accept the values
 * of title and description and simply return an object
 */
const appRouter=router({
    createTodo:publicProcedure
    .input(todoInputType)
    .mutation(async (opts)=>{
        const title=opts.input.title;
        const description=opts.input.description;
        //here we are going to use the context
        console.log(opts.ctx.username)
        //some DB stuff is supposed to occur here
        return {
            id:"1"
        }
    })
});

/**creating server with "appRouter" as a router
 * plus we are also creating a context such that before the request reaches the router 
 * the createContext is going to run where we would first check the headers (not available to router)
 * and then  return the value of the context based on the type which we gave during initialisation
 * and here its {
 * username:"abc"
 * }
 * createContext can be seen as more of a middleware for passing some extra info to the procedures
*/
const server = createHTTPServer({
    router: appRouter,
    createContext(opts){
        let authHeader=opts.req.headers["authorization"];
        console.log(authHeader);
        //...some  other stuff
        return {
            username:"abc",
        }
    }
  });

//server gets activated on port 3000
server.listen(3000);

//here we export the type of appRouter 
export type AppRouter = typeof appRouter;

The line opts.ctx lets us use the context . Now the part left is simply sending the authorisation header from the client . For that , we will need to make some simple changes to the index.ts in the client . Lets see the changes:

import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server';
//     ๐Ÿ‘† **type-only** import

// Pass AppRouter as generic here. ๐Ÿ‘‡ This lets the `trpc` object know
// what procedures are available on the server and their input/output types.
const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000',
      //adding headers here
      headers(){
        return {
          Authorisation:"Bearer 123"
        }
      }
    }),
  ],
});

async function main(){
    let response=await trpc.createTodo.mutate({
        title:"abc",
        description:"def"
    })

    console.log(response)
}
main();
/**
 * Here we are directly able to execute the controller
 * or the function such that we are also sending the request body instead of making call like this:
 * await axios.post("http://localhost:..../api/something",{
 * title:"...",
 * description:"..."}
 * )
 * 
 * Plus it also helps you giving types , you can hover over response
 * and see its type you can see the type of value you need to pass to mutate
 */

You just need to add the headers function alongside to the url property . You could see the overall logic like this that we send an authorisation bearer token which first gets taken care of in the createContext function such that the value for the context is returned and then we proceed to the procedure where we can access the context value . Here you can see createContext as a middleware .

Wrapping up

I hope you were able to understand the basics of tRPC . Next we will also need to look at the React and NextJS integration of tRPC . So that's it , thank you for reading !

ย