Generics without the abstract nonsense. One real example.
Generics let functions and types work with any type while preserving type information. One practical example makes them concrete.
Most explanations of generics start with containers — a Box<T> or a Stack<T>. These examples are pedagogically correct but too abstract. Let’s start with a problem that actually appears in real code.
The problem without generics
You have a function that fetches data from an API and returns it:
async function fetchData(url: string): Promise<any> {
const res = await fetch(url);
return res.json();
}
const user = await fetchData("/api/user/1");
user.name; // TypeScript thinks user is 'any' — no type information
Using any loses all type safety after the call. TypeScript cannot help you if you access user.naem (typo). The whole point of TypeScript is gone.
You could overload the function for each return type:
async function fetchUser(url: string): Promise<User> { ... }
async function fetchPost(url: string): Promise<Post> { ... }
But now you have duplicate logic. Every change to the fetch logic must be made in every overload.
Generics solve this
async function fetchData<T>(url: string): Promise<T> {
const res = await fetch(url);
return res.json() as T;
}
const user = await fetchData<User>("/api/user/1");
user.name; // TypeScript knows this is a User — full type information
user.naem; // Error: Property 'naem' does not exist on type 'User'
T is a type parameter. When you call fetchData<User>, you are telling TypeScript “use User as the type for T throughout this call.” The function stays generic, but each call site has full type information.
Generic functions: the syntax
function identity<T>(value: T): T {
return value;
}
identity<string>("hello"); // returns string
identity<number>(42); // returns number
The <T> after the function name declares the type parameter. T is a placeholder that TypeScript fills in at each call site.
TypeScript can usually infer the type parameter from the arguments, so you often don’t need to write it explicitly:
identity("hello"); // TypeScript infers T = string
identity(42); // TypeScript infers T = number
A real-world example: a typed wrapper
Here is a generic that appears in almost every project — a typed wrapper for localStorage:
function getStorageItem<T>(key: string): T | null {
const raw = localStorage.getItem(key);
if (raw === null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function setStorageItem<T>(key: string, value: T): void {
localStorage.setItem(key, JSON.stringify(value));
}
// Usage
setStorageItem<UserPreferences>("prefs", { theme: "dark", lang: "en" });
const prefs = getStorageItem<UserPreferences>("prefs");
// prefs is UserPreferences | null — TypeScript knows the shape
Without generics this would either use any (unsafe) or require a separate function for each type stored.
Generic constraints
Sometimes you need T to have certain properties. Use extends to constrain:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // string
getProperty(user, "age"); // number
getProperty(user, "email"); // Error: "email" doesn't exist on this object
K extends keyof T means “K must be one of the keys of T.” TypeScript also knows the return type is T[K] — the type of the value at that key. If you access name, the return type is string. If you access age, it is number.
Generic interfaces and types
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
type PaginatedResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
async function getUsers(): Promise<ApiResponse<PaginatedResponse<User>>> {
const res = await fetch("/api/users");
return res.json();
}
const response = await getUsers();
response.data.items[0].name; // TypeScript knows this is a User
The type composition is explicit. At any level of nesting, TypeScript knows what type to expect.
Default type parameters
interface Container<T = string> {
value: T;
label: string;
}
const c1: Container = { value: "hello", label: "text" }; // T defaults to string
const c2: Container<number> = { value: 42, label: "count" };
Default type parameters let consumers omit the type parameter when the default is appropriate.
The pattern to internalize: generics preserve type information through transformations. Without them, information is lost at function boundaries. With them, TypeScript can track types through the full call chain.