Maximum type safety across the entire stack. How to setup a fullstack app with Angular and tRPC.

emoji_objects emoji_objects emoji_objects
Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Jan 24, 2023

6 min read

Angular & tRPC
share

There has been a lot of chatter about tRPC in the React community on Twitter and YouTube. That piqued my curiosity, so I decided to give it a go in Angular. Here is what I found.

What is tRPC?

tRPC is a compact and efficient RPC (Remote Procedure Call) framework that prioritizes simplicity, speed, and ease of use.

With tRPC, you can invoke server-side functions from the client as if they were local objects. It supports building distributed systems across a range of programming languages.

The standout advantage of tRPC is achieving type safety without relying on code generation.

This article is also available as a Youtube video on my YouTube channel.

Setting up a tRPC server

Kicking off a tRPC server means starting a fresh npm project and pulling in the required dependencies.

tRPC integrates with various Node backend frameworks, such as express and fastify. In this guide, we'll stick with fastify. We'll also bring in zod to validate incoming request payloads.

npm init
npm i @trpc/server fastify fastify-cors zod

Once those packages are in place, the next move is to create a server.ts file.

import fastify from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';

import { todosRouter } from './todo/todo.route';

const dev = true;
const port = 3000;

function createServer() {
  const server = fastify({ logger: dev });

  server.register(cors, {
    origin: true,
  });
  server.register(fastifyTRPCPlugin, {
    trpcOptions: { router: todosRouter },
  });

  server.get('/', async () => {
    return { hello: 'wait-on 💨' };
  });

  const stop = () => server.close();
  const start = async () => {
    try {
      await server.listen(port);
      console.log('listening on port', port);
    } catch (err) {
      server.log.error(err);
      process.exit(1);
    }
  };
  return { server, start, stop };
}

createServer()
  .start()
  .then(() => console.log(`server starter on port ${port}`));

If you've worked with fastify before, this code should look familiar. We're booting up a fastify server listening on port 3000 and turning on CORS. However, there are some notable tRPC-specific pieces scattered throughout, and we should break those down.

The first thing you'll notice is the import of fastifyTRPCPlugin from @trpc/server/adapters/fastify. That plugin gets registered on the fastify instance, wiring up a tRPC router to the server.

The router itself hasn't been defined yet, so let's build one next.

The tRPC router

Inside a tRPC setup, the router is responsible for directing incoming requests to the right server or service. Think of it as the central exchange point: it accepts client requests, figures out which server should handle them, and then routes the responses back.

In practice, a tRPC router takes on several duties:

  • Listen for incoming client requests.

  • Map each request to the proper server or service based on the method and service name.

  • Send the request onward to the intended server or service.

  • Collect the response returned by that server or service.

  • Send the response back to the client.

The real win here is that a tRPC router lets you hide the underlying network complexity of your distributed system. That abstraction makes it simpler to add, remove, and scale servers and services. Additional perks can include load balancing, data-based routing, and service discovery.

For our purposes, we're building a router that handles CRUD operations for a simple TODO app. Let's walk through it.

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

const t = initTRPC.create();

const publicProcedure = t.procedure;
const router = t.router;

let id = 1;

let todos = [
  {
    id: 0,
    todo: 'Clean the kitchen',
    done: false,
  },
  {
    id: 1,
    todo: 'Bring out the trash',
    done: false,
  },
];

export const todosRouter = router({});

export type TodosRouter = typeof todosRouter;

The first step is to import initTRPC from @trpc/server and z from zod. Calling initTRPC.create() gives us a tRPC instance, which in turn lets us define a publicProcedure and a router.

After setting up those constants, we seed some initial todos for our app's state. Next, we define a router (with more details in a moment), and finally we export the type of todosRouter. That exported type becomes critical later when we start calling methods from the client side.

Now it's time to hammer out some routes. We'll start with one that returns our todo list.

export const todosRouter = router({
  todos: publicProcedure.query((_) => todos),
});

Pretty straightforward, right? For standard GET-style calls, we reach for the .query method on our publicProcedure instance. The callback we pass to .query is a simple function that hands back the todos array.

Alright, let's move on to functions that alter our todos. Consider, for instance, a method that adds a todo.

export const todosRouter = router({
  todos: publicProcedure.query((_) => todos),
  addTodo: publicProcedure
    .input(
      z.object({
        todo: z.string(),
        done: z.boolean(),
      }),
    )
    .mutation(({ input }) => {
      const newTodo = {
        id: ++id,
        ...input,
      };
      todos.push(newTodo);
      return newTodo;
    }),
});

On the object we hand to the router, we added a new key named addTodo. Notice that this time we chained both .input and .mutation onto the publicProcedure.

The .input call is our gatekeeper for validating function arguments, and we use zod for that purpose.

Following that, we combine .mutation with a resolver to implement the logic behind adding a fresh Todo. One key point to remember: the input to the resolver arrives as a property named input on the argument object. Destructuring it as ({input}) gives us direct access.

That covers the basics, and we can round out the router by adding the remaining CRUD methods.

export const todosRouter = router({
  todos: publicProcedure.query((_) => todos),
  addTodo: publicProcedure
    .input(
      z.object({
        todo: z.string(),
        done: z.boolean(),
      }),
    )
    .mutation(({ input }) => {
      const newTodo = {
        id: ++id,
        ...input,
      };
      todos.push(newTodo);
      return newTodo;
    }),
  updateTodo: publicProcedure
    .input(
      z.object({
        id: z.number(),
        todo: z.string(),
        done: z.boolean(),
      }),
    )
    .mutation(({ input }) => {
      todos = todos.map((t) => (t.id === input.id ? input : t));
      return input;
    }),
  deleteTodo: publicProcedure.input(z.number()).mutation(({ input }) => {
    const todoToDelete = todos.find((todo) => todo.id === input);
    todos = todos.filter((todo) => todo.id !== input);
    return todoToDelete;
  }),
});

With that, we've got a working fastify server with a tRPC router. Time to flip over to the Angular side and start invoking those remote functions.

Everything covered in this post was built live on my Twitch stream. If modern web development is your thing—or you just want to hang out—head over and subscribe to my channel so you don't miss any upcoming streams.

The tRPC client

In a typical Angular app, backend calls live inside services and rely on the HTTPClient. Here's an example service for a Todo app that hits several REST endpoints.

@Injectable({
  providedIn: 'root',
})
export class TodoService {
  constructor(private http: HttpClient) {}

  public getAllTodos(): Observable<Todo[]> {
    return this.http.get<Todo[]>(ENDPOINT);
  }

  public addTodo(todo: CreateAndUpdateTodo): Observable<Todo> {
    return this.http.post<Todo>(ENDPOINT, todo);
  }

  public updateTodo(todo: Todo): Observable<Todo> {
    return this.http.patch<Todo>(`${ENDPOINT}/${todo.id}`, {
      todo: todo.todo,
      done: todo.done,
    });
  }

  public deleteTodo(id: number): Observable<Todo> {
    return this.http.delete<Todo>(`${ENDPOINT}/${id}`);
  }
}

Standard stuff. But our target isn't REST—we want to make calls to remote functions through tRPC. So, let's add the @trpc/client package and give that service a refactor.

npm i @trpc/client

To get going, we need to create a client instance. That means pulling in two helper functions from @trpc/client: createTRPCProxyClient and httpBatchLink.

private client = createTRPCProxyClient<TodosRouter>({
    links: [
      httpBatchLink({
        url: 'http://localhost:3000',
      }),
    ],
});

The critical piece here is the generic type passed to createTRPCProxyClient. But where exactly does TodosRouter come from?

Remember earlier when I flagged an important line on the backend side?

export type TodosRouter = typeof todosRouter;

That's the type we want to bring into the frontend. So, we'll add an import like this to our client service.

import type {TodosRouter} from '../../../todo-backend/todo/todo.route';

private client = createTRPCProxyClient<TodosRouter>({
    links: [
      httpBatchLink({
        url: 'http://localhost:3000',
      }),
    ],
});

Once that's wired up, tRPC takes over from there, delivering maximal end-to-end type safety.

Now, on to the fun part: calling functions! Let's kick things off by pulling down our Todos.

Invoking query functions

public getAllTodos(): Observable<Todo[]> {
  return fromPromise(this.client.todos.query());
}

Here, we use our client to call the .todos.query function. What gets returned is a Promise, and to make it play nicely with Angular, we wrap it using fromPromise to convert it into an Observable. That gives us an experience close to what HTTPClient offers.

The best part is the IDE support you get thanks to the tRPC magic.

Angular & tRPC - Angular Experts — figure 3

The editor instantly knows which remote functions are available in our codebase. That's a nice touch. However, the todos function takes no input and returns just the list. To see if type safety reaches mutations as well, let's test it with addTodo.

Calling mutations

Mutations work very much like .query calls. For adding a Todo, we invoke .addTodo.mutate, passing in our new todo.

public addTodo(todo: CreateAndUpdateTodo): Observable<Todo> {
  return fromPromise(this.client.addTodo.mutate(todo));
}

What happens if we try to pass a plain string where a todo object is expected?

Angular & tRPC - Angular Experts — figure 4

Exactly as expected, the compiler fires back with a warning. A string just isn't assignable to the expected shape of {todo: string, done: boolean}. That's full-fledged type safety in motion.

Summary

tRPC is a genuinely impressive piece of tooling. It opens the door to calling backend functions straight from the frontend, putting the full might of TypeScript to work across the entire stack.

By using tRPC, your front and back ends stay permanently in sync. Instead of generating code, you get a contract defined purely in TypeScript—all without extra tooling.

Naturally, this approach assumes a TypeScript backend and that the backend and frontend code both live in the same repository.

Do you enjoy the theme of the code preview? Explore our brand new theme plugin

Skol - the ultimate IDE theme

Skol - the ultimate IDE theme

Northern lights feeling straight to your IDE. A simple but powerful dark theme that looks great and relaxes your eyes.

Build smarter UIs with Angular + AI

Angular + AI Video Course

Angular + AI Video Course

A hands-on course showing how to integrate AI into Angular apps using Hash Brown to build intelligent, reactive UIs.

Learn streaming chat, tool calling, generative UI, structured outputs, and more — step by step.

Prepare yourself for the future of Angular and become an Angular Signals expert today!

Angular Signals Masterclass eBook

Angular Signals Mastercalss eBook

Learn why Angular Signals are crucial, explore their full API surface, and understand what happens under the hood.

Sharpen your skills and get ready for the next generation of Angular development. Start today!

Angular Signal Forms: Hands-On Masterclass

Angular Signal Forms: Hands-On Masterclass

Get comfortable with Angular's new Signal-Forms through 12 carefully designed chapters combining theory with practical exercises.

Cover form fundamentals, validation logic, custom control creation, handling subforms, planning migrations, and much more.

Stay updated with new posts

Win win deal illustration

Subscribe to the Angular Experts newsletter and we'll let you know the moment we publish fresh content about Angular, NgRx, RxJS, or other exciting frontend topics.

Your email stays private and you retain full control—unsubscribe whenever you like!

Occasionally, emails may contain promotional material—for details, see our Privacy policy.

## Questions & Feedback

Feel free to ask questions and share your personal insights and experiences on this subject.

## Recommended Reading

Browse these related articles from Angular Experts to deepen your knowledge on topics such as Angular or TypeScript!

Top 10 Angular Architecture Mistakes You Really Want To Avoid

Top 10 Angular Architecture Mistakes You Really Want To Avoid

Angular continues to evolve rapidly in 2024, yet the core architectural principles stay constant, making this knowledge timeless and invaluable.

Tomas Trajan

Tomas Trajan

@tomastrajan

Sep 10, 2024

15 min read

Angular Signal Inputs

Angular Signal Inputs

Transform your Angular components with the new reactive Signal Inputs.

Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Jan 24, 2024

6 min read

Improving DX with new Angular @Input Value Transform

Improving DX with new Angular @Input Value Transform

Move beyond traditional getters and setters! Discover how to use custom transformers or the built-in booleanAttribute and numberAttribute.

Kevin Kreuzer

Kevin Kreuzer

@nivekcode

Nov 18, 2023

3 min read

## Partner with us

Angular Experts has spent years consulting for both enterprises and early-stage startups, leading workshops, and contributing to well-known open source projects. We are proud of our frontend expertise and ready to help your business succeed.