TypeScript Generics: Write It Once, Stay Type-Safe Forever
Author
Jeff Kershner
Date Published

If you've been writing TypeScript for a while, you've probably copy-pasted some version of the same fetch wrapper across multiple projects. It works... until it doesn't. You lose your types somewhere between the API call and the component that consumes the data, and suddenly you're back in JavaScript territory, chasing down runtime errors that TypeScript was supposed to prevent.
Generics are the fix. And once they click, you'll wonder how you ever lived without them.
The Problem: any Kills Your Type Safety
Let's start with a pattern almost every TypeScript developer has written:
1async function fetchData(url: string): Promise<any> {2 const res = await fetch(url);3 return res.json();4}
It works. It runs. But the moment you return any, TypeScript stops helping you. Your editor won't autocomplete. You won't get a warning if you typo a property name. You've essentially opted out of the type system for everything downstream of this function.
Now multiply that across a codebase with 20 different API endpoints, and you've got a maintenance headache waiting to happen.
The Solution: A Generic API Response Wrapper
Here's where generics shine. Instead of telling TypeScript "I don't know what this returns," you say "I don't know yet, but the caller will tell me."
1type ApiResponse<T> = {2 data: T | null;3 error: string | null;4 loading: boolean;5};67async function fetchData<T>(url: string): Promise<ApiResponse<T>> {8 try {9 const res = await fetch(url);10 const data: T = await res.json();11 return { data, error: null, loading: false };12 } catch (e) {13 return { data: null, error: (e as Error).message, loading: false };14 }15}
The <T> is a type parameter — a placeholder that gets filled in when you call the function. Think of it as a promise: "Whatever type you pass me, I'll give it back to you safely."
Using It in Practice
Now you can use the same function across your entire app, fully typed:
1type User = { id: number; name: string; email: string };2type Product = { id: number; name: string; price: number };34// Fetching a user5const userResponse = await fetchData<User>('/api/users/1');6if (userResponse.data) {7 // TypeScript knows this is a string8 console.log(userResponse.data.name);9}1011// Fetching a product12const productResponse = await fetchData<Product>('/api/products/42');13if (productResponse.data) {14 // TypeScript knows this is a number15 console.log(productResponse.data.price);16}
Your editor will autocomplete the properties. If you typo userResponse.data.name, TypeScript will catch it before your code ever runs. And if your User type changes like, you rename email to emailAddress, TypeScript will immediately flag every place in your codebase that needs to be updated.
One function. Infinite reuse. Zero any.
Taking It Further: Generic Constraints
Sometimes you want to be flexible and enforce a contract. That's where constraints come in with the extends keyword:
1type WithId = { id: number };23async function fetchById<T extends WithId>(4 url: string,5 id: number6): Promise<ApiResponse<T>> {7 return fetchData<T>(`${url}/${id}`);8}
Now fetchById works with any type — as long as it has an id property. You get flexibility and safety at the same time.
Why This Matters in Real Codebases
In production apps, especially in B2B SaaS products where data models evolve constantly, this pattern pays dividends fast:
Refactoring is safer. Change a type definition and TypeScript tells you everywhere it breaks.
Onboarding is faster. New engineers can understand what a function returns just by reading its signature.
Less defensive code. You don't need runtime checks for properties that TypeScript already guarantees exist.
The generic API wrapper is one of those patterns that starts as a small utility and quietly becomes load-bearing infrastructure for your entire frontend.
The Mental Model That Makes Generics Click
Stop thinking of <T> as a template or a macro. Think of it as a type that travels with your data.
When you call fetchData<User>(), you're not just telling TypeScript what comes back from the function, you're threading the User type through every layer of the function's logic. The return type knows about it. The error handling knows about it. Every consumer of that function inherits that type knowledge automatically.
That's not just a developer experience improvement. That's a correctness guarantee.
Wrapping Up
Generics are one of those TypeScript features that feel abstract until the moment they solve a real problem. The API response wrapper is a perfect entry point because it's immediately useful, easy to understand, and demonstrates both pillars of what generics offer: code reuse and type safety, without sacrificing one for the other.
Start with this pattern. Once it's in your muscle memory, you'll start seeing opportunities to reach for generics everywhere: utility hooks, state management helpers, form handlers, data transformers. They're not a corner-case feature. They're the tool that takes TypeScript from "types bolted onto JavaScript" to a genuinely powerful type system.
Write it once. Stay type-safe forever.

TypeScript is beating out JavaScript. If you are still writing JavaScript, read this article.
Jeff Kershner
Engineering Leader & Lifelong Coder | Co-Founder of AI Startup Deployed in 1,200+ Retail Locations