TypeScript Generics: Write Once, Work Everywhere
Stop writing the same function for every type or reaching for `any`. TypeScript generics let you write it once — with full type inference, constraints, and zero casting.
TypeScript Generics: Write Once, Work Everywhere
At some point you write a utility function and realize it only works for one type. So you write a second version for another type. Then a third. By the fourth you either reach for any — which silently destroys every guarantee TypeScript was giving you — or you copy-paste the same logic into yet another file and swear at yourself quietly.
Generics are the fix. They let you write a function, interface, or class once and have TypeScript figure out the concrete types at each call site. No any, no duplicates, no loss of inference.
The Problem: Copy-Paste or Give Up
Here's a function that wraps an API response in a consistent envelope:
// Bad: only works for User responses
function wrapUserResponse(data: User, success: boolean) {
return { data, success, timestamp: new Date() };
}
// Bad: same code, different type
function wrapPostResponse(data: Post, success: boolean) {
return { data, success, timestamp: new Date() };
}
// Also bad: you've thrown away all type information
function wrapAnyResponse(data: any, success: boolean) {
return { data, success, timestamp: new Date() };
}Three problems here. The first two are duplicates — any change to the envelope structure has to be made in N places. The third compiles fine but now wrapAnyResponse(...).data is any: you've lost the type of whatever you passed in.
The Pattern: Generic Functions
A generic function takes a type parameter — written inside <T> — that gets substituted at each call site based on what you actually pass:
function wrapResponse<T>(data: T, success: boolean) {
return {
data, // type is T
success,
timestamp: new Date(),
};
}
// TypeScript infers T = User from the argument
const userResponse = wrapResponse(user, true);
// userResponse.data is User — full autocomplete, no assertions
const postResponse = wrapResponse(post, true);
// postResponse.data is PostOne function. TypeScript infers the type from whatever you pass in. You almost never need to write wrapResponse<User>(user, true) explicitly — the inference handles it.
Constraining Generics with extends
Without constraints, T can be literally anything — string, null, () => void. That's useful sometimes, but often you need to guarantee the type has certain properties.
Shape constraints:
// T must have an `id` field
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
const user = findById(users, "u_123"); // User | undefined
const post = findById(posts, "p_456"); // Post | undefined
// TypeScript rejects this — numbers don't have `id`
findById([1, 2, 3], "123"); // Errorkeyof constraints — type-safe property access:
This is one of the most useful generic patterns you'll actually reach for:
// K must be a key of T — prevents accessing properties that don't exist
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const emails = pluck(users, "email"); // string[]
const ids = pluck(users, "id"); // string[]
// Compile error — "password" doesn't exist on User
pluck(users, "password"); // Property 'password' does not existWithout K extends keyof T, you'd need to cast or use any. With it, TypeScript knows the return type is T[K] — the actual type of that property.
Generic Interfaces and Types
Functions aren't the only place generics show up. Interfaces and type aliases are where you'll use them most often in practice:
// A standard API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// A paginated list response
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasNextPage: boolean;
}
// Usage — the concrete type flows through automatically
async function fetchUsers(): Promise<ApiResponse<User[]>> {
const res = await fetch("/api/users");
return res.json();
}
async function fetchPosts(page: number): Promise<PaginatedResponse<Post>> {
const res = await fetch(`/api/posts?page=${page}`);
return res.json();
}Now fetchUsers().then(r => r.data) gives you User[] with full autocomplete. No casting needed.
Generic interfaces for callbacks and handlers:
interface EventHandler<TPayload> {
(payload: TPayload): void | Promise<void>;
}
// Typed event bus
interface EventMap {
"user:created": { userId: string; email: string };
"user:deleted": { userId: string };
"payment:completed": { amount: number; currency: string; orderId: string };
}
function on<K extends keyof EventMap>(
event: K,
handler: EventHandler<EventMap[K]>
): void {
// register handler
}
on("user:created", ({ userId, email }) => {
// userId is string, email is string — TypeScript knows
console.log(`New user: ${email}`);
});
on("payment:completed", ({ amount, currency }) => {
// amount is number, currency is string
console.log(`Payment: ${amount} ${currency}`);
});
// Error — payload type doesn't match
on("user:created", ({ amount }) => {}); // Property 'amount' does not existDefault Type Parameters
Generics can have defaults, just like function parameters:
interface RequestConfig<TBody = Record<string, unknown>> {
url: string;
method: "GET" | "POST" | "PUT" | "DELETE";
body?: TBody;
headers?: Record<string, string>;
}
// Uses the default — body is Record<string, unknown>
const config: RequestConfig = { url: "/api/data", method: "GET" };
// Override the default
const loginConfig: RequestConfig<{ email: string; password: string }> = {
url: "/api/login",
method: "POST",
body: { email: "user@example.com", password: "secret" },
};Defaults prevent the type parameter from being mandatory when the caller doesn't care about it.
Conditional Types and infer
This is where generics get genuinely powerful — and where most tutorials stop being useful because the examples are too abstract. Let's look at real cases.
The infer keyword lets TypeScript extract a type from within another type:
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer R> ? R : T;
type UserData = Awaited<Promise<User>>; // User
type StringData = Awaited<string>; // string (not a Promise, returns as-is)(This is actually how the built-in Awaited<T> utility type works.)
Extracting array element types:
type ElementOf<T> = T extends (infer E)[] ? E : never;
type PostElement = ElementOf<Post[]>; // Post
type NumElement = ElementOf<number[]>; // number
type Nope = ElementOf<string>; // never — not an arrayFunction parameter and return type extraction:
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type ParamsOf<T> = T extends (...args: infer P) => any ? P : never;
declare function fetchUser(id: string): Promise<User>;
type FetchUserReturn = ReturnOf<typeof fetchUser>; // Promise<User>
type FetchUserParams = ParamsOf<typeof fetchUser>; // [string]These are essentially how TypeScript's built-in ReturnType<T> and Parameters<T> utility types are implemented.
Practical use: a typed API fetcher
type ApiRoute = {
"/users": { GET: User[]; POST: { body: CreateUserInput; response: User } };
"/posts": { GET: Post[]; POST: { body: CreatePostInput; response: Post } };
"/users/:id": { GET: User; DELETE: void };
};
// Extract the GET response type for a route
type GetResponseFor<Route extends keyof ApiRoute> =
ApiRoute[Route] extends { GET: infer R } ? R : never;
type UsersGetResponse = GetResponseFor<"/users">; // User[]
type UserGetResponse = GetResponseFor<"/users/:id">; // Userinfer only works inside conditional types. You can't use it in function bodies or standalone type positions — only in the extends clause of a conditional type.
A Real Pattern: Generic Repository
Here's a pattern that shows up constantly in backend TypeScript code — a generic repository that gives you typed CRUD operations without reimplementing them per model:
interface Repository<TModel, TCreateInput, TUpdateInput = Partial<TCreateInput>> {
findById(id: string): Promise<TModel | null>;
findMany(filter?: Partial<TModel>): Promise<TModel[]>;
create(data: TCreateInput): Promise<TModel>;
update(id: string, data: TUpdateInput): Promise<TModel>;
delete(id: string): Promise<void>;
}
// Concrete implementation for a specific model
class UserRepository implements Repository<User, CreateUserInput> {
async findById(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}
async findMany(filter?: Partial<User>): Promise<User[]> {
return prisma.user.findMany({ where: filter });
}
async create(data: CreateUserInput): Promise<User> {
return prisma.user.create({ data });
}
async update(id: string, data: Partial<CreateUserInput>): Promise<User> {
return prisma.user.update({ where: { id }, data });
}
async delete(id: string): Promise<void> {
await prisma.user.delete({ where: { id } });
}
}The Repository<User, CreateUserInput> interface is a contract. Any code that depends on Repository<User, CreateUserInput> can be tested with a mock that also implements the same interface. The generic parameters are what make that swappable.
Generic React Components
In React, generics let you build typed UI components that work with any data shape:
// A typed Select component that preserves the option type
interface SelectProps<T> {
options: T[];
value: T | null;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
}
function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
return (
<select
value={value ? getValue(value) : ""}
onChange={(e) => {
const selected = options.find((o) => getValue(o) === e.target.value);
if (selected) onChange(selected);
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage — onChange gives you User, not string
<Select<User>
options={users}
value={selectedUser}
onChange={(user) => setSelectedUser(user)} // user is User, not string
getLabel={(u) => u.name}
getValue={(u) => u.id}
/>The key insight: onChange gets a User back, not a string. Without generics, you'd return a string ID and have to look up the object yourself every time.
In .tsx files, write <T,> (with a trailing comma) to disambiguate from JSX — <T> alone looks like an opening JSX tag to the compiler.
Mapped Types: Transforming Every Key
Mapped types iterate over union types and create a new type from them. They're how utility types like Partial<T>, Readonly<T>, and Record<K, V> work:
// All fields optional
type Partial<T> = { [K in keyof T]?: T[K] };
// All fields readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Transform every value type
type Stringify<T> = { [K in keyof T]: string };
type StringifiedUser = Stringify<User>;
// { id: string; email: string; name: string; createdAt: string; ... }A practical one — making specific fields nullable for a form state:
type FormState<T> = {
[K in keyof T]: {
value: T[K];
error: string | null;
touched: boolean;
};
};
type LoginFormState = FormState<{ email: string; password: string }>;
// {
// email: { value: string; error: string | null; touched: boolean };
// password: { value: string; error: string | null; touched: boolean };
// }When NOT to Use Generics
Generics are easy to overuse. A few signs you've gone too far:
When unknown or a union type is clearer. If you just want to accept "anything and return it unchanged", reach for unknown instead of T. It's more honest about what the function actually does.
When you're adding T just to pass it through. If T doesn't appear in the return type or any constraint, it's not doing work:
// Pointless — T is never used in a meaningful way
function logGeneric<T>(value: T): void {
console.log(value);
}
// Better
function log(value: unknown): void {
console.log(value);
}When the caller never gets different types back. If doThing<User>() and doThing<Post>() produce the same return type regardless of T, the generic isn't providing safety — it's just noise.
When you reach for as unknown as T anywhere in the implementation. That's the generic equivalent of any. It compiles, but TypeScript isn't actually verifying anything anymore.
as unknown as T inside a generic function is a red flag. It means the type system can't verify your claim. If you find yourself writing it, double-check that the generic is actually the right tool for the job.
One-off transformations. A utility that runs exactly once in your codebase doesn't need a generic. The abstraction cost isn't worth it.
Putting It Together: A Typed Cache
Here's a small, complete example that combines several of these ideas:
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
class TypedCache<TKey extends string, TValue> {
private store = new Map<TKey, CacheEntry<TValue>>();
set(key: TKey, value: TValue, ttlMs: number): void {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs,
});
}
get(key: TKey): TValue | null {
const entry = this.store.get(key);
if (!entry || Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
has(key: TKey): boolean {
return this.get(key) !== null;
}
}
// User session cache — keys must be strings, values are Session objects
const sessionCache = new TypedCache<string, Session>();
sessionCache.set("sess_abc", session, 15 * 60 * 1000); // 15 min TTL
const cached = sessionCache.get("sess_abc"); // Session | nullTKey extends string isn't strictly necessary here (Map already accepts string keys), but it documents intent: only string keys are valid, and TypeScript will enforce that at the call site.
The Mental Model
Generics are type-level functions. Just as a regular function takes a value and returns a value, a generic takes a type and returns a type. wrapResponse<T> takes T = User and returns { data: User; success: boolean; timestamp: Date }.
Once that clicks, the extends keyword reads naturally — it's the type-level equivalent of a function parameter type annotation. <T extends { id: string }> is just saying: "this type parameter must satisfy this shape constraint."
And infer is pattern matching on types — the same idea as destructuring a value, but for extracting sub-types from a composite type.
Use generics when:
- The same logic needs to work across multiple types with the same structure
- You want callers to get back the exact type they put in (not a widened union)
- You're building a reusable library, utility, or component that others will use with their own types
Avoid them when:
- You're handling a genuinely heterogeneous collection where
unknown[]is accurate - The generic doesn't appear in the return type or any constraint (it's not doing work)
- The implementation requires you to cast away type safety to make it compile
Comments (0)
No comments yet. Be the first to share your thoughts!