11 October 2022
by
Hector Sosa

tRPC: Understanding Typesafety

typescript
trpc
next.js
Type safety is the extent to which a programming language prevents type errors. The process of verifying and enforcing the constraints of types may occur at compile time or run-time. A programming language like TypeScript checks a program for errors before execution (at compile time) as a static type checker.
In contrast, a library like Zod can also provide type checking at run-time. So how does a library like tRPC helps us better understand type safety?
tRPC allows you to easily build and consume fully typesafe APIs, without schemas or code generation.
At its core, tRPC provides the solution to statically type our API endpoints and share those types between our client and server, enabling type safety from end-to-end.

How does tRPC share types between client/server?

Types are shared based on one or many procedures contained in Routers. A procedure is a composable query, mutation, or subscription where you define how your client/server interacts with each other.
Let's see what you'd need to create a query procedure for a Next.js application. We'll explore these concepts by reviewing our tRPC-basic-starter GH repo. Here's what our file structure initially looks like:
1# @path: ./src
2├── pages
3│   └── api/trpc
4│       └── [trpc].ts # <-- tRPC HTTP handler
5│   └── _app.tsx      # <-- tRPC Provider
6│   └── index.tsx
7│   └── [...]
8├── server
9│   └── routers
10│       └── _app.ts   # <-- Main app router
11│       └── user.ts   # <-- User sub-router
12│       └── [...]     # <-- More sub-routers
13│   └── trpc.ts       # <-- Procedure helpers
14├── utils
15│   └── trpc.ts       # <-- Typesafe tRPC hooks
You could define all of your procedures within the tRPC HTTP handler and completely skip the server directory. However, this wouldn't be a scalable approach. Your backend will likely require several endpoints, for which it is recommended to separate your procedures into different sub-routers and merge them as suggested in the file structure above.
To create a query procedure, at the most basic level we need to define an input (optional and validated with your library of choice), and a query (the actual implementation of the procedure) which runs a function returning the data you need. In the end, the types of each router are exported to provide a fully-typed experience on the client without importing any server code.
1// @path: ./src/server/routers/user.ts
2import { t } from "../trpc";
3import { z } from "zod";
4
5export const userRouter = t.router({
6    // Define a procedure (function) that
7    // ...takes an input and provides a query
8    // ...as `user.greet.useQuery()`
9    greet: t.procedure
10        // Input validation
11        .input(z.object({ name: z.string() }))
12        .query(({ input }) => {
13            // Here you would process
14            // any information you'd need
15            // ..to return it to your client
16            return { text: `Hello, ${input.name}!` };
17        }),
18});

Using your new tRPC-backend on the client

@tRPC/react provides a set of hooks wrapped around @tanstack/react-query, so under the hood, they work just the same to fetch data from a server. You'll notice that the conventional querying keys and functions are defined within your procedure.
1// @path: ./src/pages/index.tsx
2import { trpc as t } from "../utils/trpc";
3
4export default function Home() {
5    // Wrapped around @tanstack/react-query
6    // Can also destructure to access
7    // isLoading, isError, isSuccess, error and data
8    const result = t.user.greet.useQuery({ name: "Client" });
9
10    if (result.isLoading) {
11        return (
12            <div>
13                <h1>Loading...</h1>
14            </div>
15        );
16    }
17
18    return (
19        <div>
20            <h1>{result.data?.text}</h1>
21        </div>
22    );
23}
...and that's the basic setup. Both the result and input are type-inferred from the procedures as defined and will get TypeScript autocompletion and IntelliSense that matches your backend API without requiring any code generation.
Banner

Do you need a reliable partner in tech for your next project?

More examples of tRPC usage

Let's create an additional sub-router @path: ./src/server/routers/post.ts where we need to provide our client with the following: (a) fetch all posts, (b) fetch post by ID, (c) create a new post. Notice that requirements for a and b are different than for c, as the first two are query procedures and the last one requires a mutation procedure. However, you will notice that there is no difference between queries and mutation apart from semantics.
1import { t } from "../trpc";
2import { z } from "zod";
3
4type PostType = {
5    userId: number;
6    id: number;
7    title: string;
8    body: string;
9};
10
11export const postRouter = t.router({
12    // Define a procedure that
13    // ...doesn't require an input and provides a query
14    // ...as `post.allPosts.useQuery()`
15    allPosts: t.procedure.query(async () => {
16        const allPosts = await fetch(
17            "https://jsonplaceholder.typicode.com/posts"
18        ).then((response) => response.json());
19        return { posts: posts as Array<PostType> };
20    }),
21    // Define a procedure that
22    // ...takes an id and provides a query
23    // ...as `post.postById.useQuery({ id })`
24    postById: t.procedure
25        .input(z.object({ id: z.string() }))
26        .query(async ({ input }) => {
27            const post = await fetch(
28                `https://jsonplaceholder.typicode.com/posts/${input.id}`
29            ).then((response) => response.json());
30            return { post: post as PostType };
31        }),
32});

How can we use procedures?

Procedures are able to resolve any custom function to process a validated { input }. Just to name a few examples: you could make use of an ORM like Prisma, a Baas like Supabase, or a headless CMS like Sanity to process your data with the benefits of fully typesafe APIs.

What about Mutations?

Mutations are typically used to create/update/delete data. Let's take a look at how we could create a mutation procedure using our Post sub-router:
1import { t } from "../trpc";
2import { z } from "zod";
3
4type PostType = {
5    userId: number;
6    id: number;
7    title: string;
8    body: string;
9};
10
11export const postRouter = t.router({
12    // Define a procedure that
13    // ...takes an id and executes a mutation
14    // ...as `post.createPost.useMutation()`
15    // ...from `*.mutate({ input })`
16    createPost: t.procedure
17        .input(
18            z.object({
19                title: z.string().min(1),
20                body: z.string().min(1),
21                userId: z.number(),
22            })
23        )
24        .mutation(async ({ input }) => {
25            const post = await fetch(
26                "https://jsonplaceholder.typicode.com/posts",
27                { method: "POST", body: JSON.stringify(input) }
28            ).then((response) => response.json());
29            return { response: post as PostType };
30        }),
31});
Here is how we could execute the mutation from the client:
1import { FormEvent, ChangeEvent, useState } from "react";
2import { trpc as t } from "../../utils/trpc";
3
4export default function NewPost() {
5    // User is fixed for simplicity
6    const initValue = { title: "", body: "", userId: 1 };
7    const [form, setForm] = useState(initValue);
8    const mutation = t.post.createPost.useMutation();
9
10    function handleChange(
11        e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
12    ) {
13        setForm({ ...form, [e.target.id]: e.target.value });
14    }
15
16    function handleSubmit(e: FormEvent<HTMLFormElement>) {
17        e.preventDefault();
18        mutation.mutate(form);
19        setForm(initValue);
20    }
21
22    return (
23        <form onSubmit={handleSubmit}>
24            <label htmlFor="title">Title</label>
25            <input
26                type="text"
27                id="title"
28                name="title"
29                value={form.title}
30                onChange={handleChange}
31            />
32            <label htmlFor="body">Body</label>
33            <textarea
34                id="body"
35                name="body"
36                value={form.body}
37                onChange={handleChange}
38            />
39            <button type="submit">Submit</button>
40        </form>
41    );
42}
Mutations are as simple to do as queries, they're actually the same underneath, but are just exposed differently as syntactic sugar and produce an HTTP POST rather than a GET request.

So WIFY by using tRPC?

What's in it for you using tRPC? We barely scratched the surface, here is a list of features:
  • Full static type-safety with auto-completion on the client, inputs, outputs, and errors.
  • No code generation, run-time bloat, or build pipeline.
  • Zero dependencies and a tiny client-side footprint.
  • All requests can be batched automatically into one.
  • Framework agnostic and compatible with all JavaScript frameworks and runtimes.
Feel free to explore this example using the resources below:
Share post

Let’s stay connected

Do you want the latest and greatest from our blog straight to your inbox? Chuck us your email address and get informed.
You can unsubscribe any time. For more details, review our privacy policy