TypeScript Generics: From Confused to Comfortable
A ground-up explanation of TypeScript generics -- generic functions, interfaces, classes, constraints, mapped types, and conditional types. With real patterns you'll actually use.
Generics look scary the first time you see them. Angle brackets everywhere, single-letter type parameters, and error messages that span three lines. But the core idea is straightforward, and once it clicks, you'll wonder how you ever wrote TypeScript without them.
You Already Use Generics
Every time you write Array or Promise, you're using a generic. Array isn't a type on its own -- it's a type that takes a parameter. "An array... of what?"
const names: Array<string> = ['Alice', 'Bob'];
const ids: Array<number> = [1, 2, 3];
That's generics. A type that's parameterized by another type. The rest is just learning to write your own.
Generic Functions
Say you want a function that returns the first element of an array. Without generics:
function first(arr: any[]): any {
return arr[0];
}
const name = first(['Alice', 'Bob']); // type: any -- useless
You lost the type information. With a generic:
function first<T>(arr: T[]): T {
return arr[0];
}
const name = first(['Alice', 'Bob']); // type: string
const id = first([1, 2, 3]); // type: number
T is a type variable. TypeScript infers it from the argument. You passed a string[], so T becomes string, and the return type is string. Type safety preserved, code reused.
You don't usually need to specify T manually -- inference handles it. But you can when it helps:
const result = first<number>([1, 2, 3]);
Generic Interfaces
This is where generics start paying off in real code. A typed API response:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
title: string;
price: number;
}
// Now your API calls are fully typed
async function fetchUser(id: number): Promise<ApiResponse<User>> { ... }
async function fetchProducts(): Promise<ApiResponse<Product[]>> { ... }
One ApiResponse interface works for every entity in your app. The shape of the wrapper is consistent, but the data type changes.
Generic Classes
Same idea, applied to classes. A generic repository pattern:
class Repository<T extends { id: number }> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
getAll(): T[] {
return [...this.items];
}
}
const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: 'Alice', email: 'alice@test.com' });
const user = userRepo.findById(1); // type: User | undefined
Notice the extends { id: number } -- that's a constraint.
Constraints with extends
Sometimes T can't be literally anything. You need it to have certain properties:
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength('hello'); // works -- strings have length
getLength([1, 2, 3]); // works -- arrays have length
getLength({ length: 5 }); // works
getLength(42); // ERROR -- number doesn't have length
The extends keyword here means "T must be assignable to this shape." It's not inheritance -- it's a constraint.
keyof and Mapped Types
keyof gives you a union of all property names of a type:
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
Combine it with generics for a type-safe property accessor:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user: User = { id: 1, name: 'Alice', email: 'alice@test.com' };
const name = getProperty(user, 'name'); // type: string
const id = getProperty(user, 'id'); // type: number
getProperty(user, 'age'); // ERROR -- 'age' not in User
That T[K] return type is an indexed access type -- "the type of property K on type T." TypeScript narrows the return type based on which key you pass. That's genuinely useful.
Mapped types let you transform every property:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string }
These two (Readonly and Partial) are built into TypeScript already, but seeing how they work demystifies a lot of utility types.
Default Type Parameters
Just like function parameters can have defaults:
interface PaginatedResult<T, Meta = { page: number; total: number }> {
items: T[];
meta: Meta;
}
// Uses default Meta
const result: PaginatedResult<User> = {
items: [{ id: 1, name: 'Alice', email: 'a@b.com' }],
meta: { page: 1, total: 50 }
};
Conditional Types
These read like ternary expressions at the type level:
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
A practical use -- extracting the resolved type from a Promise:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // number
The infer keyword captures a type from inside the pattern. It's like a regex capture group but for types.
When to Use Generics (and When Not To)
Use generics when you're writing a function, class, or type that should work with multiple types while preserving type information. Don't reach for them when a simple union type or overload will do. If your generic has three type parameters with constraints and conditional types, you've probably over-engineered it.
Start simple: Array, Promise, a typed wrapper. Build from there.
For hands-on practice with TypeScript generics and type system challenges, CodeUp has interactive exercises where you can experiment with these patterns and get instant feedback.