Skip to Content
Course content

68: Typing tRPC-Style End-to-End APIs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this happen constantly when developers first try to build "type-safe" APIs. They create a folder called /shared, define an interface like interface UserProfile { id: string; name: string }, and import that interface into both their Express backend and their React frontend. They think they've achieved end-to-end type safety. They haven't.

Here is why that approach is a trap: your interface is a claim, not a fact. If you change the database schema to return userId instead of id but forget to update that shared interface, your TypeScript compiler will stay silent. Your frontend will think it's getting an id, your code will compile perfectly, and then your app will crash in production with undefined. You've basically just built a manual documentation system that you have to remember to keep in sync.

The "Shared Interface File" Fallacy

Let's look at the "wrong way" first, so we can appreciate the alternative. Imagine we are building a simple task manager. You might be tempted to do this:

// shared/types.ts
export interface Task {
  id: string;
  title: string;
  isCompleted: boolean;
}

// server.ts
app.get('/task', (req, res) => {
  // If I accidentally change this to 'completed' instead of 'isCompleted',
  // the 'Task' interface doesn't stop me from sending the wrong data.
  res.json({ id: '1', title: 'Fix bugs', completed: true }); 
});

// client.ts
const task: Task = await fetchTask();
console.log(task.isCompleted); // TypeScript says this is fine, but it's actually undefined!

The disconnect is that the server is returning a value, but the client is casting that value to a type you wrote by hand. There is no actual link between the implementation of the API and the consumption of it.

Inference via the Router Type

The "tRPC-style" approach flips this on its head. Instead of writing interfaces, we let TypeScript infer the types from the server's actual implementation. We don't export the logic; we export the type of the router. This is where the real magic happens.

I prefer this because it creates a single source of truth. If I change a field name in the server function, the client immediately turns red. No manual updates required.

// server.ts
import { z } from 'zod';

// We define our logic first
const appRouter = {
  getTask: async (id: string) => {
    return { id, title: "Learn Inference", isCompleted: false };
  },
  updateTask: async ({ id, title }: { id: string, title: string }) => {
    return { success: true };
  }
};

// This is the key: we export ONLY the type of the router, not the router itself.
export type AppRouter = typeof appRouter;

Now, on the client, we don't import appRouter (which would pull in all your server-side dependencies and potentially crash the browser). We import the AppRouter type. Using some clever generics—which is how tRPC is built under the hood—the client can now "know" exactly what getTask returns without us ever writing an interface.

Closing the Loop with Input Validation

End-to-end safety isn't just about what the server returns; it's about what the server accepts. If you're just using any or basic types on the server, you're still vulnerable to runtime crashes.

To truly finish the tRPC pattern, you should use a schema validation library like Zod. This allows the server to validate the input at runtime, while simultaneously providing the TypeScript type for the client. I've found that combining Zod with router inference is the only way to truly sleep soundly at night when deploying API changes.

// server.ts (refined)
const appRouter = {
  updateTask: async (input: z.infer<typeof TaskSchema>) => {
    // Logic here...
  }
};

const TaskSchema = z.object({
  id: z.string(),
  title: z.string().min(3),
});

When the client calls this, they aren't just guessing that title should be a string; they are inheriting the requirement that it must be a string of at least 3 characters, derived directly from the server's validation logic.




📋 Practical Task

Build a Type-Inferred "User Settings" API

Your goal is to implement a server-client type link for a User Settings feature without using any shared interfaces.

  • Server Side: Create an appRouter object. It should have a method called getUserSettings that returns an object containing theme (string), notificationsEnabled (boolean), and fontSize (number).
  • The Link: Export the type of this router as AppRouter using the typeof operator.
  • Client Side: Create a mock client function apiCall<T>. Use the AppRouter type to ensure that when you call apiCall('getUserSettings'), the return type is automatically inferred as the settings object.
  • The Test: Try to assign the result of the client call to a variable with a wrong type (e.g., fontSize: string) and verify that TypeScript throws an error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.