TypeScript Generics Are Not Scary: Real-World Generic Components
- typescript
- generics
- frontend
- react
- advanced
From generic hooks to generic API clients — how infer, extends, and conditional types eliminate any in real code.
The “I Just Used any” Pipeline
Every codebase starts with good intentions. Then a useQuery hook needs to return different shapes. Or a Table component needs to accept different data types. Or an API client needs to handle varying response schemas.
// Year one
function useData(url: string) {
const [data, setData] = useState<any>(null);
// ...
}
// Year two
function Table({ columns, data }: { columns: any[]; data: any[] }) {
// ...
}
// Year three — rewrite
The any escape hatch works until it does not. When a refactor breaks silently, when data is undefined at runtime but the type says any, the cost of skipping generics compounds.
Generics are not academic. They are the difference between a component that lies to its consumers and one that bakes its contract into the type system.
Generic Hooks: useQuery<T>
The simplest generic is a function that captures the type of its argument or return value:
function useQuery<T>(url: string): { data: T | null; loading: boolean; error: Error | null } {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((json: T) => {
setData(json);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, [url]);
return { data, loading, error };
}
Usage:
type User = { id: string; name: string; email: string };
function UserProfile({ userId }: { userId: string }) {
const { data, loading } = useQuery<User>(`/api/users/${userId}`);
if (loading) return <Skeleton />;
// `data` is `User | null` — autocomplete works, property access is safe
return <p>{data?.name}</p>;
}
No any. No type assertions in the consumer. The generic parameter T flows from the call site through the hook and back to the consumer.
Generic React Components
A Table component that accepts any row type is the textbook case:
type Column<T> = {
key: keyof T;
header: string;
render?: (value: T[keyof T], row: T) => React.ReactNode;
};
function Table<T extends Record<string, unknown>>({
columns,
data,
}: {
columns: Column<T>[];
data: T[];
}) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={col.key as string}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.key as string}>
{col.render
? col.render(row[col.key], row)
: (row[col.key] as React.ReactNode)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
The constraint T extends Record<string, unknown> ensures only objects are accepted. The keyof T in the column definition means passing an invalid column key is a compile-time error:
type Product = { id: number; name: string; price: number };
const columns: Column<Product>[] = [
{ key: "name", header: "Name" },
{ key: "price", header: "Price" },
{ key: "nonexistent", header: "Oops" }, // Error: '"nonexistent"' is not assignable
];
The extends Constraint
The extends keyword constrains what types can be plugged into a generic. Without it, T could be anything — string, number, undefined. With it, you communicate intent to both the compiler and future readers.
Practical constraints:
// Only objects
function toQueryString<T extends Record<string, string | number | boolean>>(
params: T,
): string {
return Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}
// Only functions
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = fn(...args);
cache.set(key, result);
return result;
}) as T;
}
The infer Keyword
infer is the most misunderstood tool in TypeScript’s generic toolbox. It lets you extract a type from inside another type, like picking a lock without the key.
Extracting the return type of a function:
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => string;
type Result = ReturnOf<Fn>; // string
This is how Parameters<T>, ReturnType<T>, Awaited<T> and every other utility type in the standard library works internally.
A real-world example — unwrapping a Promise:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<string>; // string
Combine it with generics to build a type-safe event emitter:
type EventMap = {
userCreated: { id: string; name: string };
userDeleted: { id: string };
error: { message: string; code: number };
};
function on<E extends keyof EventMap>(
event: E,
handler: (payload: EventMap[E]) => void,
): void {
// implementation
}
on("userCreated", (payload) => {
// payload is { id: string; name: string }
console.log(payload.name);
});
Conditional Types: Making Impossible States Impossible
Conditional types let you change the output type based on a condition at the type level:
type ApiResponse<T, E = { message: string }> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };
A generic hook that narrows the return type based on a flag:
function useQuery<T, E = Error, S extends boolean = false>(
url: string,
options?: { skip?: S },
): S extends true
? { data: null; loading: false; error: null }
: { data: T | null; loading: boolean; error: E | null } {
// implementation
}
This is advanced, but the pattern appears in libraries like TanStack Query. When skip is true, the return type excludes loading and error entirely. The compiler enforces at the call site that you cannot access data while skip is true.
Generic API Client
Putting it all together — a type-safe API client:
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type RequestOptions<TBody = undefined> = {
method?: HttpMethod;
body?: TBody;
headers?: Record<string, string>;
};
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<TResponse, TBody = undefined>(
path: string,
options?: RequestOptions<TBody>,
): Promise<TResponse> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: options?.method ?? "GET",
headers: {
"Content-Type": "application/json",
...options?.headers,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json() as Promise<TResponse>;
}
get<TResponse>(path: string): Promise<TResponse> {
return this.request<TResponse>(path, { method: "GET" });
}
post<TResponse, TBody>(path: string, body: TBody): Promise<TResponse> {
return this.request<TResponse, TBody>(path, { method: "POST", body });
}
}
type CreateUserRequest = { name: string; email: string };
type CreateUserResponse = { id: string; name: string; email: string };
const api = new ApiClient("/api");
// No any — response type is inferred
const user = await api.post<CreateUserResponse, CreateUserRequest>("/users", {
name: "Alice",
email: "alice@example.com",
});
// user is CreateUserResponse
Every endpoint call is type-checked. The request body and response type are linked at the call site. A schema change in an endpoint surfaces immediately as a type error.
What You Gain
Generics replace the question “What type is this?” with the compiler answering before the code runs.
- No
any. Every value carries its type from source to consumer. - Compile-time errors over runtime surprises. Invalid column keys, wrong response shapes, missing parameters — caught in CI, not in production.
- Self-documenting interfaces.
Table<Product>says more thanTable data={products} columns={...}.
The initial cost is writing the generic. The ongoing cost of any is debugging why undefined is not a function at 3 AM.
any is a type-erasure operation. Every time you write it, you unplug the compiler’s safety net. Generics are the net — learn them once, collect the dividends forever.
Have a project in mind? Let's build something together.
Get in Touch