If you've been writing TypeScript for a while but generics still feel like something
you copy-paste from Stack Overflow without fully understanding, this is the article to fix that.
Generics are the feature that takes TypeScript from "JavaScript with type annotations" to a
genuinely expressive type system. Once they click, you'll see them everywhere — and you'll reach
for them naturally instead of defaulting to any when things get tricky. The
TypeScript Handbook's generics chapter
covers the full spec; this article focuses on the patterns you'll actually use in real codebases.
The Problem With any
Generics exist to solve a specific problem: you want a function or data structure to
work with multiple types, but you don't want to throw away type information in the process.
The naive solution is any — and it looks fine until you realise what you've given up:
// With any — TypeScript has no idea what comes out
function identity(arg: any): any {
return arg;
}
const result = identity('hello');
// result is typed as 'any' — you've lost the string type
// TypeScript won't catch this:
result.toFixed(2); // no error at compile time, crashes at runtimeThe type information goes in, but it doesn't come out. Whatever you do with
result is completely unchecked. That's not a TypeScript codebase — that's JavaScript
with extra steps. Generics solve this by letting you say: "I don't know the exact type yet, but
whatever goes in should come back out with the same type."
// With a generic — T flows through the function
function identity<T>(arg: T): T {
return arg;
}
const result = identity('hello');
// result is typed as 'string' ✅
result.toFixed(2); // ❌ TypeScript catches this: Property 'toFixed' does not exist on type 'string'
const count = identity(42);
// count is typed as 'number' ✅The <T> declares a type parameter — think of it as a variable for
types. TypeScript infers what T is from the argument you pass in, so you almost
never need to write it explicitly. identity<string>('hello') works, but so
does identity('hello') — TypeScript figures it out.
Generic Functions in Practice
The identity function is the canonical teaching example but not something you'd write in production. Here are the kinds of generic functions that actually show up in real codebases:
// A type-safe array first/last helper
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const firstUser = first(users); // UserProfile | undefined
const lastOrder = last(orders); // Order | undefined
const firstNum = first([1, 2, 3]); // number | undefined
// Group array items by a key
function groupBy<T, K extends string | number>(
items: T[],
getKey: (item: T) => K
): Record<K, T[]> {
return items.reduce((acc, item) => {
const key = getKey(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {} as Record<K, T[]>);
}
// TypeScript infers T as Order and K as string
const byStatus = groupBy(orders, order => order.status);
// byStatus: Record<string, Order[]>
// byStatus['pending'] is Order[] ✅Notice how TypeScript infers T and K automatically from how
you call the function. You get full type safety on the return value without writing a single
explicit type annotation at the call site. That's the payoff.
Generic Interfaces and Types
This is where generics become indispensable for everyday work. Every codebase that talks to an API ends up needing a handful of generic wrapper types. Here are the ones you'll see (and write) constantly:
// API response wrapper — used across every endpoint
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
// Paginated list response
interface PaginatedResult<T> {
items: T[];
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// Using them with concrete types
interface UserProfile {
id: number;
name: string;
email: string;
avatarUrl: string;
}
interface Order {
id: number;
userId: number;
total: number;
status: 'pending' | 'shipped' | 'delivered';
}
// The return types are fully typed — no casting needed downstream
async function getUser(id: number): Promise<ApiResponse<UserProfile>> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function listOrders(page: number): Promise<PaginatedResult<Order>> {
const res = await fetch(`/api/orders?page=${page}`);
return res.json();
}
const response = await getUser(1);
response.data.email; // ✅ string
response.data.avatarUrl; // ✅ string
const result = await listOrders(1);
result.items[0].status; // ✅ 'pending' | 'shipped' | 'delivered'
result.totalPages; // ✅ numberUserProfile, Order) automatically from a sample response. You
then plug those into your generic wrappers.Generic Constraints
Sometimes you want to accept multiple types, but not absolutely any type. Generic
constraints let you specify what a type parameter must have. The syntax is
T extends SomeType — which means "T must be assignable to SomeType":
// Without constraint — TypeScript doesn't know T has an id property
function findById<T>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ❌ Property 'id' does not exist on type 'T'
}
// With constraint — T must have at least an id: number field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id); // ✅
}
// Works with any type that has id: number
const user = findById(users, 42); // UserProfile | undefined
const order = findById(orders, 101); // Order | undefined
// Another common constraint — T must be an object (excludes primitives)
function mergeDefaults<T extends object>(partial: Partial<T>, defaults: T): T {
return { ...defaults, ...partial };
}
// T must have a name: string and email: string
function formatContact<T extends { name: string; email: string }>(contact: T): string {
return `${contact.name} <${contact.email}>`;
}
// Works on any object with those two fields — UserProfile, Employee, whatever
formatContact({ name: 'Alice', email: '[email protected]', role: 'admin' }); // ✅The constraint doesn't lock T to exactly { id: number } —
it means T must have at least that shape. So passing a full
UserProfile with ten fields is fine. This is structural typing in action, which is
one of TypeScript's most powerful and
well-documented
features.
The keyof Constraint
One of the most useful generic patterns in the TypeScript standard library and real
codebases is combining generics with keyof. It lets you write functions that
accept a property name of an object type and guarantee the return type matches that property.
// keyof T is the union of all keys of T as string literals
// K extends keyof T means K must be one of those keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: UserProfile = {
id: 1,
name: 'Alice',
email: '[email protected]',
avatarUrl: 'https://example.com/avatar.png'
};
const name = getProperty(user, 'name'); // string ✅
const id = getProperty(user, 'id'); // number ✅
const email = getProperty(user, 'email'); // string ✅
getProperty(user, 'password'); // ❌ Argument of type '"password"' is not assignable to
// parameter of type 'keyof UserProfile'
// Real use case: a generic sort function
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
const av = a[key];
const bv = b[key];
return av < bv ? -1 : av > bv ? 1 : 0;
});
}
const byName = sortBy(users, 'name'); // ✅ sorted by name
const byId = sortBy(users, 'id'); // ✅ sorted by id
sortBy(users, 'nonexistent'); // ❌ caught at compile timeDefault Type Parameters
Just like function parameters can have defaults, type parameters can too. This is useful when you have a generic type that's almost always used with a specific type, but you want to keep the flexibility for the cases when it's not.
// Default type parameter — T defaults to string if not specified
interface Cache<T = string> {
get(key: string): T | undefined;
set(key: string, value: T, ttlMs?: number): void;
delete(key: string): void;
clear(): void;
}
// Without specifying T — uses the default (string)
declare const stringCache: Cache;
const val = stringCache.get('theme'); // string | undefined
// Specifying a different T
declare const userCache: Cache<UserProfile>;
const user2 = userCache.get('user:42'); // UserProfile | undefined
// Another useful example: an event emitter with a typed payload
interface TypedEvent<TPayload = void> {
subscribe(handler: (payload: TPayload) => void): () => void;
emit(payload: TPayload): void;
}
// Events with no payload — default void keeps the API clean
const appReady: TypedEvent = { /* ... */ };
appReady.emit(); // no argument needed
// Events with a payload
const userLoggedIn: TypedEvent<{ userId: number; timestamp: Date }> = { /* ... */ };
userLoggedIn.emit({ userId: 42, timestamp: new Date() });Built-in Generic Utility Types
TypeScript ships a set of built-in utility types that are all implemented using the generics you've learned above. Understanding how to use them is one of the most practical TypeScript skills you can have. Here's the core set with real examples showing the output type:
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'viewer';
passwordHash: string;
createdAt: Date;
}
// Partial<T> — all fields become optional (great for PATCH payloads)
type UpdateUserPayload = Partial<UserProfile>;
// { id?: number; name?: string; email?: string; role?: ...; ... }
// Required<T> — all fields become required (reverse of Partial)
interface DraftConfig {
apiUrl?: string;
timeout?: number;
maxRetries?: number;
}
type ResolvedConfig = Required<DraftConfig>;
// { apiUrl: string; timeout: number; maxRetries: number }
// Pick<T, K> — keep only the named fields
type UserSummary = Pick<UserProfile, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
// Use in list views — only ship what the UI needs
// Omit<T, K> — exclude the named fields
type PublicUserProfile = Omit<UserProfile, 'passwordHash'>;
// { id: number; name: string; email: string; role: ...; createdAt: Date }
// Safe to include in API responses
// Record<K, V> — typed dictionary / map
type PermissionMap = Record<UserProfile['role'], string[]>;
// { admin: string[]; user: string[]; viewer: string[] }
const permissions: PermissionMap = {
admin: ['read', 'write', 'delete', 'admin'],
user: ['read', 'write'],
viewer: ['read']
};
// ReturnType<T> — infer what a function returns (keeps types in sync automatically)
function buildUserSession(user: UserProfile) {
return {
token: crypto.randomUUID(),
userId: user.id,
role: user.role,
expiresAt: new Date(Date.now() + 3_600_000)
};
}
type UserSession = ReturnType<typeof buildUserSession>;
// { token: string; userId: number; role: 'admin' | 'user' | 'viewer'; expiresAt: Date }
// Change buildUserSession and UserSession updates automatically ✅If you're converting existing JavaScript to TypeScript and building these types from scratch, the JS to TypeScript tool can help you get the initial types in place so you can start layering utility types on top.
A Realistic End-to-End Example: Typed API Client
Here's the pattern I reach for every time I'm building a typed API layer. It combines generic functions, generic interfaces, and utility types into something that gives you end-to-end type safety from the fetch call all the way to the component consuming the data:
// The response envelope — wraps every API response
interface ApiResponse<T> {
data: T;
meta: {
requestId: string;
duration: number;
};
}
// Error envelope — what the API sends on failure
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
// The generic fetch wrapper
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options
});
if (!res.ok) {
const err: ApiError = await res.json();
throw new Error(`[${err.code}] ${err.message}`);
}
return res.json();
}
// Typed endpoint functions — T is set at each call site
async function getUser(id: number): Promise<UserProfile> {
const { data } = await fetchApi<UserProfile>(`/api/users/${id}`);
return data;
}
async function listOrders(userId: number, page = 1): Promise<PaginatedResult<Order>> {
const { data } = await fetchApi<PaginatedResult<Order>>(
`/api/users/${userId}/orders?page=${page}`
);
return data;
}
async function createOrder(
payload: Pick<Order, 'userId' | 'total'>
): Promise<Order> {
const { data } = await fetchApi<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify(payload)
});
return data;
}
// Usage — fully typed, no casting anywhere
const user = await getUser(42);
console.log(user.email); // string ✅
const orders = await listOrders(42);
orders.items[0].status; // 'pending' | 'shipped' | 'delivered' ✅
orders.totalPages; // number ✅
const newOrder = await createOrder({ userId: 42, total: 99.99 });
newOrder.id; // number ✅Common Mistakes
A few patterns to avoid once you're comfortable with generics:
- Using
anyinside a generic. If you writefunction wrap<T>(val: T): any, you've defeated the purpose. The whole point is that the type flows through — usinganyas the return type or inside the implementation means TypeScript can't track it anymore. - Over-constraining with specific types. Writing
function process<T extends UserProfile>when you only needT extends { id: number }is too restrictive. Use the minimum constraint that makes your implementation work — that way the function stays reusable across different types that happen to have the same shape. - Reaching for generics when a union would do. If a function takes either a
stringor anumberand the logic is different for each,function f(arg: string | number)with a type guard inside is cleaner than a generic. Generics shine when the same logic applies to all type variants. - Too many type parameters. If you find yourself writing
<T, U, V, W>, step back. That's usually a sign the function is doing too much, or the types could be expressed as a single interface. The TypeScript compiler source itself is a good reference — even complex utilities rarely need more than 2–3 type parameters.
Wrapping Up
Generics are the point where TypeScript stops being "annotated JavaScript" and starts
being a genuinely powerful type system. The core idea is simple: capture the type as a parameter
so it flows through your function or interface without losing information. From there, constraints
let you narrow what's acceptable, keyof gives you type-safe property access, and
the built-in utility types (Partial, Pick, Omit,
Record) handle the patterns you'd otherwise type out by hand. The best next step
is to open a real project, find a function where you used any because you didn't
know another way, and replace it with a generic. The
TypeScript Handbook on generics
and the
utility types reference
are the two pages worth bookmarking. And if you're building interfaces from real API payloads,
JSON to TypeScript can generate the concrete types —
then it's just a matter of wrapping them in your generic utility types like
ApiResponse<T> and PaginatedResult<T> to get full
end-to-end type safety.