Skip to content

Chapter 2: Building Type-Safe APIs with tRPC

Theoretical Foundations

In the previous book, we established the fundamental role of TypeScript Interfaces as the bedrock of our application's architecture. We defined them as rigid "contracts"—explicit blueprints that dictate the exact shape and structure of data objects. We learned that an interface User { id: string; name: string; } is not merely a suggestion; it is a non-negotiable law that both the data producer and consumer must adhere to. This concept of a strict, verifiable contract is the single most important prerequisite for understanding the power of tRPC.

Now, we face a new, formidable challenge inherent to distributed systems: the API Boundary. In traditional web development, the backend and the frontend are two separate, isolated worlds. The backend is written in one codebase, often in TypeScript, and the frontend in another. When the frontend needs to fetch data, it sends a request over the network—a process that inherently breaks the type system. The type safety we enjoyed within our backend codebase evaporates at the edge of the network, leaving us with ambiguous fetch calls, manually defined and often outdated interfaces for API responses, and the constant risk of runtime errors because the frontend's expectation of the data shape no longer matches the backend's reality.

This is the problem tRPC exists to solve. tRPC, which stands for Typed RPC, is a framework for building APIs that allows you to call your backend functions directly from your frontend as if they were local functions, while maintaining complete, end-to-end type safety. It eliminates the "API contract" ambiguity by making your backend's implementation the single source of truth for both the server and the client.

To truly grasp this, let's use an analogy. Imagine your backend and frontend are two colleagues working in separate offices. The traditional REST API is like them communicating via handwritten memos.

  • The Old Way (REST): The backend developer writes a memo (the API endpoint documentation) that says, "To get user data, send a GET request to /users/123. I will return a JSON object with id, name, and email." The frontend developer reads this memo and builds a system to send the request and parse the response. Months later, the backend developer updates the database and, without thinking, changes the email field to userEmail. They forget to update the memo. The frontend developer's system is now broken. It's expecting email, but it receives userEmail. This is a classic API drift.

  • The New Way (tRPC): tRPC installs a direct, private telephone line between the two offices. The backend developer defines a function called getUser. The frontend developer doesn't need to know about URLs, HTTP methods, or JSON structures. They simply "pick up the phone" and say, "Hey, getUser for user 123." Because the telephone line is directly linked to the actual function, the frontend developer gets the exact, correct data structure every single time. If the backend developer changes the function to return userEmail, the telephone system itself (TypeScript's compiler) immediately alerts the frontend developer: "Hold on, the function you're calling has changed. You need to update your code." The contract is enforced in real-time by the development environment itself.

The Mechanics of Type Safety: Procedures and the "Virtual" Network

tRPC rethinks the concept of an API endpoint. Instead of mapping URLs to handlers, tRPC organizes your API into procedures. A procedure is simply a function that can be executed on the server but is callable from the client. There are two primary types:

  1. Queries: These are procedures designed for fetching data. They are conceptually similar to GET requests in REST. They should be idempotent, meaning calling them multiple times with the same input produces the same result without changing the server's state.
  2. Mutations: These are procedures designed for changing data. They are conceptually similar to POST, PUT, or DELETE requests. They are not idempotent and are used for creating, updating, or deleting resources.

The "magic" of tRPC lies in how it constructs this virtual bridge. The backend defines these procedures using a builder pattern. This builder allows you to chain on functionalities, most importantly input validation. When you define a procedure, you specify exactly what input it expects, typically using a validation library like Zod. This Zod schema becomes the gatekeeper for your procedure.

Now, let's visualize this flow. The backend doesn't just run the code; it also exports a highly detailed type signature of its entire API. This signature is a complete description of every procedure, its required inputs, and its output type. The frontend doesn't import a URL string; it imports this type signature. It then uses this signature to generate a client-side proxy. This proxy is an object that looks and feels exactly like your backend's API router, but its methods are configured to make network calls behind the scenes.

When the frontend developer calls client.user.getById.query({ id: '123' }), the following happens under the hood:

  1. Type Checking: The TypeScript compiler, using the imported type signature, immediately verifies that { id: '123' } is a valid input for the getById query. If it's not (e.g., if id was expected to be a number), the code won't even compile.
  2. Network Call: If the types are correct, the proxy executes a network request. tRPC uses HTTP as the transport layer, but it abstracts this away. The input is serialized and sent to the server.
  3. Server-Side Validation & Execution: The server receives the request, deserializes the input, and runs it through the Zod schema validator defined in the procedure. This is a critical security step, as you should never trust the client. If validation fails, an error is returned.
  4. Execution & Return: If validation passes, the procedure's logic is executed. The return value is typed, serialized, and sent back to the client.
  5. Typed Response: The client receives the response. Because the frontend client knows the exact return type of the procedure from the imported signature, the result of the client.user.getById.query() call is automatically typed. There's no need for manual type assertions like as User. The developer gets full autocompletion on the returned data.

Middleware: The Interceptors of Logic

In any robust system, you need gatekeepers. You don't let everyone into every room. In tRPC, this role is filled by middleware. Middleware functions are executed before a procedure's main logic. They can inspect the request, modify the context, and either continue to the next middleware/procedure or terminate the request with an error.

The most common use case for middleware is authentication. Imagine a user trying to access a protected procedure, like getMyPrivateProfile. The flow looks like this:

  1. The user's frontend calls client.getMyPrivateProfile.query().
  2. The request hits the tRPC router on the server.
  3. Before reaching the getMyPrivateProfile procedure, it passes through an isAuthenticated middleware.
  4. This middleware checks for a valid session token in the request headers.
  5. If the token is invalid: The middleware immediately stops the process and throws an "UNAUTHORIZED" error. The procedure's logic is never executed.
  6. If the token is valid: The middleware decodes the token, extracts the user's information (like their userId), and adds it to the context object. The context is a special object that gets passed down to the procedure and any subsequent middleware. The middleware then allows the request to proceed.

This is incredibly powerful because it separates concerns. The procedure getMyPrivateProfile doesn't need to worry about how to check for a user; it can safely assume that if it's being executed, a valid user exists in the context. This makes the procedure's code cleaner and more focused on its single responsibility.

The diagram illustrates how the getMyPrivateProfile function can focus solely on retrieving user data because the surrounding context guarantees a valid user, eliminating the need for internal validation logic.
Hold "Ctrl" to enable pan & zoom

The diagram illustrates how the `getMyPrivateProfile` function can focus solely on retrieving user data because the surrounding context guarantees a valid user, eliminating the need for internal validation logic.

The "Why": Developer Experience and Velocity

The ultimate "why" behind tRPC is a radical improvement in Developer Experience (DX). It directly addresses the friction points that slow down development in full-stack projects.

  1. Elimination of Boilerplate: You no longer need to write OpenAPI/Swagger documentation, manually create interface files for API responses, or write separate validation logic on the client and server. The Zod schema you write for the backend is the validation for both the server runtime and the client's type-checking.
  2. Refactoring with Confidence: Imagine you need to rename a property on a data object. In a traditional REST setup, you'd have to:

    • Change the database schema.
    • Change the backend code that maps the database result to JSON.
    • Find the API documentation and update it.
    • Search the entire frontend codebase for every place that consumes this API and manually update the property name.
    • Hope you didn't miss one.

    With tRPC, you change the property in your backend's data model (and its corresponding TypeScript interface). The TypeScript compiler will immediately show red squiggly lines on the frontend wherever the old property name is being used. You fix them one by one, with the compiler guiding you. The entire process is safe, guided, and fast.

  3. Unbreakable API Contracts: By design, it's impossible for the frontend to be out of sync with the backend's API structure. The types are generated from the source of truth. This prevents an entire class of bugs that plague distributed systems.

In essence, tRPC treats your backend API not as a separate, loosely coupled service, but as a fully integrated, type-safe extension of your frontend's own codebase. It brings the safety and speed of a monolith to the world of modern, decoupled full-stack development.

Basic Code Example

In this example, we will build a minimal, self-contained tRPC backend for a SaaS application. The goal is to create a single API endpoint that returns a welcome message. While simple, this setup demonstrates the core architectural pattern: defining a procedure (the API endpoint), validating its input with Zod, and creating a router to expose it.

This example assumes a Node.js environment with TypeScript configured. It focuses purely on the backend logic that would typically be hosted on a platform like Vercel or AWS Lambda.

/**

 * @fileoverview Basic tRPC "Hello World" SaaS Backend
 * 
 * This file demonstrates a minimal tRPC server setup.
 * It defines a single query procedure that accepts a user's name
 * and returns a personalized greeting.
 */

// 1. IMPORTS
// We import the necessary tRPC libraries and Zod for schema validation.
// '@trpc/server' contains the core logic for creating routers and procedures.
// 'z' is the standard import for Zod.
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

// 2. INITIALIZATION
// The `initTRPC` function creates a set of utilities for defining our API.
// We typically call this once per application lifecycle.
const t = initTRPC.create();

// 3. MIDDLEWARE & CONTEXT (Optional for Hello World, but good practice)
// In a real SaaS app, context would carry user authentication data.
// Here, we define a simple context interface to show how it's structured.
interface Context {
  user?: {
    id: string;
    name: string;
  };
}

// 4. INPUT VALIDATION SCHEMA
// We define a Zod schema to validate the input of our procedure.
// This ensures type safety from the client all the way to the database.
const greetingInputSchema = z.object({
  name: z.string().min(1, "Name must be at least 1 character long"),
});

// 5. PROCEDURE DEFINITION
// We create a "query" procedure. In tRPC, queries are used for fetching data (GET),
// while mutations are used for modifying data (POST/PUT/DELETE).
const publicProcedure = t.procedure;

// 6. ROUTER DEFINITION
// The router aggregates procedures. Here we define a router named 'greeting'
// containing a single query named 'sayHello'.
const appRouter = t.router({
  greeting: t.router({
    sayHello: publicProcedure
      // Input validation is chained using .input()
      .input(greetingInputSchema)
      // The resolver function executes the logic. It receives the validated input.
      .query((opts) => {
        const { input } = opts;

        // In a real app, you might fetch user data from a DB here.
        // For this example, we simply return a string.
        return {
          message: `Hello, ${input.name}! Welcome to the SaaS platform.`,
          timestamp: new Date().toISOString(),
        };
      }),
  }),
});

// 7. EXPORT TYPE DEFINITIONS
// This is the most critical part for end-to-end type safety.
// We export the router's type definition to be used by the frontend client.
export type AppRouter = typeof appRouter;

// NOTE: In a real server file (e.g., index.ts), you would now pass 
// `appRouter` to an HTTP adapter like `createExpressMiddleware` or `createNextApiHandler`.

Visualizing the tRPC Flow

The following diagram illustrates how the tRPC router, procedure, and Zod validation interact within the backend context.

A backend tRPC router, defined with Zod-validated procedures, is passed to an HTTP adapter (like createExpressMiddleware or createNextApiHandler) to handle incoming client requests.
Hold "Ctrl" to enable pan & zoom

A backend tRPC router, defined with Zod-validated procedures, is passed to an HTTP adapter (like `createExpressMiddleware` or `createNextApiHandler`) to handle incoming client requests.

Line-by-Line Explanation

1. Imports

  • import { initTRPC } from '@trpc/server';
    • Why: This is the entry point for the tRPC server library. initTRPC is a factory function that creates a fresh instance of tRPC utilities (routers, procedures, middleware). This isolation is useful for complex applications or testing.
  • import { z } from 'zod';
    • Why: Zod is the standard schema validation library for TypeScript. It allows us to define runtime validation rules that are automatically inferred as static TypeScript types. This prevents "hallucinated" or malformed data from entering our system.

2. Initialization

  • const t = initTRPC.create();
    • How: We call .create() to instantiate the tRPC core. The object t now contains properties like router, procedure, and middleware.
    • Under the Hood: This creates a context-aware instance. While we aren't using complex context here, this setup allows for dependency injection (like database connections) later on.

3. Context Interface

  • interface Context { ... }
    • Why: In a SaaS application, every request usually carries context (e.g., who is making the request?). Even though our "Hello World" doesn't require authentication, defining the Context interface early establishes the contract for future middleware (like checking JWT tokens).

4. Input Validation Schema

  • const greetingInputSchema = z.object({ name: z.string().min(1) });
    • How: We define a schema that expects an object with a property name. Zod enforces that name must be a string and at least 1 character long.
    • Under the Hood: Zod creates a validation function. When a request comes in, tRPC runs the input through this function. If validation fails, tRPC automatically sends a structured error response to the client without executing the resolver.

5. Procedure Definition

  • const publicProcedure = t.procedure;
    • Why: This creates a reusable base procedure. In a real app, you might chain middleware here (e.g., t.procedure.use(isAuthed)). For this example, we keep it simple.

6. Router Definition

  • const appRouter = t.router({ ... });
    • How: We define a router named greeting which contains the sayHello query.
    • .input(greetingInputSchema): This chains the Zod schema to the procedure. tRPC uses this to infer the input type for the frontend client.
    • .query((opts) => { ... }): This defines the execution logic. opts.input is now fully typed and validated. We return an object containing a message and a timestamp.
    • Under the Hood: tRPC serializes the return value to JSON. The type of this return value is automatically inferred and will be available to the frontend client.

7. Type Export

  • export type AppRouter = typeof appRouter;
    • Why: This is the "magic" of tRPC. By exporting the type of the router, we can use a tRPC client on the frontend (e.g., in a React component) that has full autocomplete and type checking for API calls, effectively eliminating API contract mismatches.

Common Pitfalls

  1. Missing Zod Input Definition:

    • Issue: If you define a procedure without .input(), the opts.input object inside the resolver will be typed as unknown. This forces you to manually cast types or perform unsafe runtime checks, defeating the purpose of tRPC.
    • Fix: Always define a Zod schema, even for simple inputs, to leverage automatic type inference.
  2. Async/Await Loops in Resolvers:

    • Issue: tRPC resolvers can be async, but if you accidentally create a blocking loop (e.g., while(true) or a heavy synchronous calculation) inside a resolver, it will hang the server instance handling that request. Since Node.js is single-threaded for the event loop, this can block the entire server.
    • Fix: Ensure all I/O operations (DB calls, file reads) are awaited properly. Offload heavy CPU tasks to worker threads or background job queues (like BullMQ) rather than running them directly in the API resolver.
  3. Vercel/AWS Lambda Timeouts:

    • Issue: Serverless functions (like Vercel Edge or AWS Lambda) have strict execution time limits (often 10 seconds for standard functions, 30 seconds for Pro). If your tRPC procedure performs a long-running database query or external API call, the request will time out, returning a 504 error to the client.
    • Fix: Optimize database queries. Use indexes. For long-running tasks, use a "fire-and-forget" pattern where the API immediately returns a "Job Started" response, and the client polls a separate status endpoint or uses WebSockets for updates.
  4. TypeScript Interface vs. Type in tRPC:

    • Issue: While you can use type aliases for your data models, tRPC relies heavily on the inference capabilities of Zod and TypeScript objects. Using interface for defining the shape of API responses (like the return type of the query) is standard, but mixing manual interface definitions with inferred tRPC types can lead to drift.
    • Fix: Rely on tRPC's inference. Do not manually write interfaces for API responses; let typeof appRouter generate them for the frontend. Use interfaces for internal domain models (like database entities) that are not directly exposed via tRPC.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.