TypeScript generics have a reputation problem. People either avoid them because they look intimidating, or they overuse them until simple code feels like a puzzle. I try to stay in the middle. Generics are fantastic when they let one piece of code adapt safely to multiple shapes. They are not fantastic when they turn a readable codebase into a type algebra contest. My rule is simple: if the generic makes the intent clearer and reduces duplication, it stays. If it only makes me feel clever, it goes.
What a generic is really doing
At the most practical level, a generic is a placeholder for a type that will be provided later. That means the function, object, or component can stay reusable without losing type safety. The important idea is not the angle bracket syntax. The important idea is that the code can preserve relationships between inputs and outputs.
If I pass an array of users into a helper, I want the helper to return users, not some vague any shaped blob. Generics let TypeScript keep that relationship intact. Once you understand that, they stop feeling mysterious.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const user = first([{ name: 'Zach', role: 'dev' }]);
// user is { name: string; role: string } | undefinedThe generic patterns I use most often
Data preserving helpers
The most common place I use generics is in helpers that should preserve the shape of what they receive. Sorting, filtering wrappers, API response utilities, object mappers, and collection helpers are good examples. In each case, I want the function to stay flexible while still telling me exactly what comes back.
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
return 0;
});
}
const sorted = sortBy(users, 'name');
// sorted is still User[]This is where generics feel natural. I am not introducing a complex abstraction. I am letting the type system follow the real behavior of the code.
Reusable components and hooks
In React work, generics become useful when a component or hook needs to work with multiple data shapes but still preserve strong typing. Tables, select controls, form helpers, and state wrappers are common examples. If the component needs to know how to access labels, IDs, or keys from different records, a well designed generic can keep the calling code clean.
The trick is to define only the constraints you truly need. If the hook just needs a record with an id, say that. Do not model the whole universe.
A generic fetch wrapper
This is one of the most useful patterns I reach for in every project. A typed fetch wrapper that knows what shape the response will be:
interface ApiResponse<T> {
data: T;
error: string | null;
status: number;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<ApiResponse<T>> {
try {
const res = await fetch(url, init);
if (!res.ok) {
return { data: null as unknown as T, error: res.statusText, status: res.status };
}
const data: T = await res.json();
return { data, error: null, status: res.status };
} catch (err) {
return { data: null as unknown as T, error: String(err), status: 0 };
}
}
// Usage
interface Project {
id: string;
name: string;
client: string;
}
const result = await fetchJson<Project[]>('/api/projects');
// result.data is Project[] with full autocompleteWithout the generic, I would either use any (losing all type safety at the call site) or duplicate the fetch logic for every response shape. The generic version gives me one function that works everywhere and still provides accurate types.
Generic table component props
Tables are a perfect use case for generics in React. I want a single table component that accepts any array of records and a column configuration, but still knows what fields are available:
interface Column<Row> {
key: keyof Row;
label: string;
render?: (value: Row[keyof Row], row: Row) => React.ReactNode;
}
interface TableProps<Row> {
data: Row[];
columns: Column<Row>[];
onRowClick?: (row: Row) => void;
}
function DataTable<Row extends Record<string, unknown>>({
data,
columns,
onRowClick,
}: TableProps<Row>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i} onClick={() => onRowClick?.(row)}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}When I use this component, TypeScript knows which keys are valid for columns based on the data I pass in. If I try to reference a column key that does not exist on the row type, the compiler catches it immediately.
Constrained generics with extends
Sometimes a generic should not accept just anything. I use extends to constrain what types are valid. This is useful when the function needs to access specific properties:
interface HasId {
id: string | number;
}
function findById<T extends HasId>(items: T[], id: T['id']): T | undefined {
return items.find((item) => item.id === id);
}
// Works with any type that has an id
const project = findById(projects, 'proj-123');
const user = findById(users, 42);The constraint tells TypeScript that T must have an id property, but it does not limit T to only having an id. The return type preserves the full shape of whatever was passed in. This is the sweet spot of generics: just enough constraint to be useful, not so much that it becomes rigid.
Mapped types and conditional types
Mapped types let me transform one type into another. I use them for creating readonly versions, optional versions, or picking subsets of larger types:
type ReadonlyDeep<T> = {
readonly [K in keyof T]: T[K] extends object ? ReadonlyDeep<T[K]> : T[K];
};
type FormFields<T> = {
[K in keyof T]?: T[K];
};
// Make all fields of Project optional for a patch endpoint
type ProjectPatch = FormFields<Project>;Conditional types are more advanced but I reach for them when building utility types that behave differently based on what they receive:
type ApiResult<T> = T extends Array<infer U>
? { items: U[]; total: number }
: { item: T };
// ApiResult<User[]> = { items: User[]; total: number }
// ApiResult<User> = { item: User }I use these sparingly. They are powerful for library code and shared utilities, but in application code they can become hard to follow quickly. If a conditional type needs more than two branches, I usually step back and ask if a simpler union or overload would work better.
Before and after: code with and without generics
Here is a real pattern I see often. Without generics, developers end up duplicating logic for each data type:
// Without generics: duplication everywhere
async function fetchUsers(): Promise<User[]> {
const res = await fetch('/api/users');
return res.json();
}
async function fetchProjects(): Promise<Project[]> {
const res = await fetch('/api/projects');
return res.json();
}
async function fetchInvoices(): Promise<Invoice[]> {
const res = await fetch('/api/invoices');
return res.json();
}With generics, one function handles all of them:
// With generics: one function, full type safety
async function fetchList<T>(endpoint: string): Promise<T[]> {
const res = await fetch(endpoint);
return res.json();
}
const users = await fetchList<User>('/api/users');
const projects = await fetchList<Project>('/api/projects');
const invoices = await fetchList<Invoice>('/api/invoices');The generic version is shorter, easier to maintain, and still gives full type safety at every call site. If I later need to add error handling or caching, I change one function instead of three.
Where generics start going wrong
The most common problem is stacking too many type parameters. Once I see four or five generic placeholders bouncing across helper types, mapped types, and conditionals, I stop and ask whether the abstraction is earning its keep. Often it is not. The code may be technically elegant, but it is now expensive to read and risky for teammates to edit.
Another issue is introducing generics where a simple union, overload, or explicit type would be easier. Not every flexible function needs a generic. Sometimes the simplest answer really is the best answer.
I have also seen developers create generic components that accept so many type parameters that the component becomes harder to use than writing the specific version. If calling the component requires more type annotations than actual props, the abstraction is not paying for itself.
How I keep generic code readable
I use descriptive type parameter names when the default single letter would hide the intent. T for generic value data is fine in a small helper. But if the type represents a specific role like a row object or an API payload, I would rather name it clearly. Row, Payload, FormValues tell the reader what the generic represents without needing to trace the usage.
I also keep the public surface area small. If a generic utility requires several companion types just to understand how to call it, that is usually a smell. The best generic APIs feel obvious at the call site. You should not need to mentally reconstruct the type system before you can use the function.
A better goal than maximum type cleverness
My goal with TypeScript is confidence, not theatrics. I want the compiler to catch mistakes, guide refactors, and preserve trust as the codebase grows. A simpler generic that protects real behavior is more valuable than an advanced construct that nobody wants to touch later.
This matters even more on teams. The type system is part of the product experience for developers. If it slows every edit down, it is not helping enough.
When I know a generic was worth it
A generic was worth writing if it removed duplication, improved editor feedback, and made the calling code feel safer without becoming harder to understand. That is the bar I use. If it clears it, great. If not, I simplify.
For more practical engineering notes, browse the custom software work, read about React Server Components, or reach out if you need a TypeScript codebase that stays sharp under real product pressure.