tRPC: Understanding Typesafety

Héctor Sosa
5 min readOct 17, 2022

--

Typesafety 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 at 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 you type checking at run-time. So how does a library like tRPC helps us better understand typesafety?

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 interact 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 how our file structure initially looks like:

# @path: ./src
├── pages
│ └── api/trpc
│ └── [trpc].ts # <-- tRPC HTTP handler
│ └── _app.tsx # <-- tRPC Provider
│ └── index.tsx
│ └── [...]
├── server
│ └── routers
│ └── _app.ts # <-- Main app router
│ └── user.ts # <-- User sub-router
│ └── [...] # <-- More sub-routers
│ └── trpc.ts # <-- Procedure helpers
├── utils
│ └── 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. It’s very likely that your backend will 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. At the end, the types of each router are exported to provide a fully-typed experience on the client without importing any server code.

// @path: ./src/server/routers/user.ts
import { t } from '../trpc';
import { z } from 'zod';
export const userRouter = t.router({
// Define a procedure (function) that
// ...takes an input and provides a query
// ...as `user.greet.useQuery()`
greet: t.procedure
// Input validation
.input( z.object({ name: z.string() }) )
.query(({ input }) => {
// Here you would process
// any information you'd need
// ..to return it to your client
return { text: `Hello, ${input.name}!` };
}),
});

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.

// @path: ./src/pages/index.tsx
import { trpc as t } from '../utils/trpc';
export default function Home() {
// Wrapped around @tanstack/react-query
// Can also destructure to access
// isLoading, isError, isSuccess, error and data
const result = t.user.greet.useQuery({ name: 'Client' });
if (result.isLoading) {
return <div><h1>Loading...</h1></div>
}
return <div><h1>{result.data?.text}</h1></div>
}

…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.

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.

import { t } from "../trpc";
import { z } from "zod";
type PostType = {
userId: number;
id: number;
title: string;
body: string;
};
export const postRouter = t.router({
// Define a procedure that
// ...doesn't require an input and provides a query
// ...as `post.allPosts.useQuery()`
allPosts: t.procedure.query(async () => {
const allPosts = await fetch(
"https://jsonplaceholder.typicode.com/posts"
).then((response) => response.json());
return { posts: posts as Array<PostType> };
}),
// Define a procedure that
// ...takes an id and provides a query
// ...as `post.postById.useQuery({ id })`
postById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await fetch(
`https://jsonplaceholder.typicode.com/posts/${input.id}`
).then((response) => response.json());
return { post: post as PostType };
}),
});

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 on how we could create a mutation procedure using our Post sub-router:

import { t } from "../trpc";
import { z } from "zod";
type PostType = {
userId: number;
id: number;
title: string;
body: string;
};
export const postRouter = t.router({
// Define a procedure that
// ...takes an id and executes a mutation
// ...as `post.createPost.useMutation()`
// ...from `*.mutate({ input })`
createPost: t.procedure
.input(
z.object({
title: z.string().min(1),
body: z.string().min(1),
userId: z.number(),
})
)
.mutation(async ({ input }) => {
const post = await fetch(
"https://jsonplaceholder.typicode.com/posts",
{ method: "POST", body: JSON.stringify(input) }
).then((response) => response.json());
return { response: post as PostType };
}),
});

Here is how we could execute that mutation from the client:

import { FormEvent, ChangeEvent, useState } from "react";
import { trpc as t } from "../../utils/trpc";
export default function NewPost() {
// User is fixed for simplicity
const initValue = { title: "", body: "", userId: 1 };
const [form, setForm] = useState(initValue);
const mutation = t.post.createPost.useMutation();
function handleChange(
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) {
setForm({ ...form, [e.target.id]: e.target.value });
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
mutation.mutate(form);
setForm(initValue);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="title">Title</label>
<input
type="text"
id="title"
name="title"
value={form.title}
onChange={handleChange}
/>
<label htmlFor="body">Body</label>
<textarea
id="body"
name="body"
value={form.body}
onChange={handleChange}
/>
<button type="submit">Submit</button>
</form>
);
}

Mutations are as simple to do as queries, they’re actually the same underneath, but are just exposed differently as syntactic sugar and produce a 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 are a list of features:

  • Full static typesafety with autocompletion 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 are able to 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:

Originally published: tRPC: Understanding Typesafety

--

--