Jeff Kershner
TypeScript

TypeScript Generics: Write It Once, Stay Type-Safe Forever

Author

Jeff Kershner

Date Published

TypeScript Generics
4 min read
Share:

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};
6
7async 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 };
3
4// Fetching a user
5const userResponse = await fetchData<User>('/api/users/1');
6if (userResponse.data) {
7 // TypeScript knows this is a string
8 console.log(userResponse.data.name);
9}
10
11// Fetching a product
12const productResponse = await fetchData<Product>('/api/products/42');
13if (productResponse.data) {
14 // TypeScript knows this is a number
15 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 };
2
3async function fetchById<T extends WithId>(
4 url: string,
5 id: number
6): 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.

JavaScript vs TypeScript
languages

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