Generics in TypeScript
Generics are one of TypeScript’s most powerful features. They let you write flexible, reusable code while maintaining full type safety. Instead of locking your functions or classes to a single type, generics allow you to parameterize types — just like functions parameterize values.
Why Generics Matter
Imagine you need a function that returns whatever you pass into it:
function identity(arg: any): any {
return arg;
}
This works, but you’ve lost type information. When you call identity("hello"), TypeScript has no idea it returns a string. Generics solve this:
function identity<T>(arg: T): T {
return arg;
}
const str = identity("hello"); // type: string
const num = identity(42); // type: number
The <T> is a type parameter — a placeholder that gets filled in when you call the function. TypeScript infers T from the argument, so you get full type safety without extra code.
Generic Functions
Generic functions use one or more type parameters:
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const nums = [1, 2, 3];
const firstNum = firstElement(nums); // type: number
const words = ["hello", "world"];
const firstWord = firstElement(words); // type: string
You can also explicitly specify type arguments:
const explicit = firstElement<string>(["hello"]); // type: string
Generic Interfaces and Types
Generics work with interfaces too:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
}
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "Alice" },
status: 200,
message: "Success"
};
The ApiResponse<T> interface adapts to whatever data type you need — whether that’s a User, an array, or anything else.
Generic Constraints
Sometimes you need to restrict what types can be used. Use extends to add constraints:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
logLength("hello"); // OK - strings have length
logLength([1, 2, 3]); // OK - arrays have length
logLength({ length: 5 }); // OK - object has length property
// logLength(42); // Error - numbers don't have length
You can also constrain one generic type parameter based on another:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Bob", age: 30 };
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number
// getProperty(user, "email"); // Error - 'email' not in user
Generic Classes
Classes can be generic too:
class Box<T> {
contents: T;
constructor(contents: T) {
this.contents = contents;
}
get(): T {
return this.contents;
}
}
const stringBox = new Box("books");
const numBox = new Box(123);
You can even constrain generic class types:
class NamedBox<T extends { name: string }> {
item: T;
constructor(item: T) {
this.item = item;
}
introduce(): string {
return `This is ${this.item.name}`;
}
}
const box = new NamedBox({ name: "Widget", price: 10 });
console.log(box.introduce()); // This is Widget
Multiple Type Parameters
Functions can accept multiple type parameters:
function pair<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
const idName = pair(1, "Alice"); // [number, string]
const boolStr = pair(true, "enabled"); // [boolean, string]
This is useful for maps, caches, and other data structures:
function createCache<K, V>(): Map<K, V> {
return new Map();
}
const userCache = createCache<string, User>();
userCache.set("alice", { id: 1, name: "Alice" });
Default Type Parameters
You can provide default types for generics:
interface ApiResponse<T = unknown> {
data: T;
status: number;
}
const simpleResponse: ApiResponse = {
data: "ok",
status: 200
};
Here, T defaults to unknown when not specified.
Practical Example: Generic Repository
Here’s how generics shine in real-world code:
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(item: T): Promise<T>;
delete(id: string): Promise<void>;
}
interface User {
id: string;
email: string;
}
class UserRepository implements Repository<User> {
private users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
async save(user: User): Promise<User> {
this.users.set(user.id, user);
return user;
}
async delete(id: string): Promise<void> {
this.users.delete(id);
}
}
The Repository<T> interface works for any entity — User, Product, Order — while maintaining full type safety throughout.
Summary
Generics let you write code that’s both flexible and type-safe:
- Type parameters (
<T>) make functions and classes reusable - Type inference means you often don’t need to specify types explicitly
- Constraints (
extends) restrict what types are allowed - Default types provide fallback when types aren’t specified
Master generics, and you’ll write TypeScript code that’s more maintainable, more reusable, and catches more errors at compile time.