TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
68: Typing tRPC-Style End-to-End APIs
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
appRouterobject. It should have a method calledgetUserSettingsthat returns an object containingtheme(string),notificationsEnabled(boolean), andfontSize(number). - The Link: Export the type of this router as
AppRouterusing thetypeofoperator. - Client Side: Create a mock client function
apiCall<T>. Use theAppRoutertype to ensure that when you callapiCall('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.
There are no comments for now.