Generics are one of TypeScript's most powerful features. They let you create reusable components that work with multiple types while maintaining type safety. Instead of writing separate functions for each type or using any, generics let you write one function that works with any type—and TypeScript still knows what type you're working with! Let's master generics!
Why Generics?
Imagine you need a function that returns the first element of an array. Without generics, you have two bad options:
// Bad Option 1: Use 'any' (loses type safety)
function getFirst_any(arr: any[]): any {
return arr[0];
}
const numbers = [1, 2, 3];
const first = getFirst_any(numbers);
// Type of 'first' is 'any' - no type safety!
// first.toUpperCase(); // Compiles but crashes at runtime
// Bad Option 2: Write separate functions for each type
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
function getFirstBoolean(arr: boolean[]): boolean | undefined {
return arr[0];
}
// ... need a new function for every type!
// Good Solution: Generics!
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const nums = [1, 2, 3];
const firstNum = getFirst(nums); // Type: number | undefined ✅
const strs = ["hello", "world"];
const firstStr = getFirst(strs); // Type: string | undefined ✅
const bools = [true, false];
const firstBool = getFirst(bools); // Type: boolean | undefined ✅
// One function, works with all types, fully type-safe!
if (firstNum !== undefined) {
console.log(firstNum.toFixed(2)); // ✅ Works
}
if (firstStr !== undefined) {
console.log(firstStr.toUpperCase()); // ✅ Works
}💡 Generics = Reusability + Type Safety
Generics let you write code once that works with many types, while preserving complete type safety. They're like function parameters, but for types!
Basic Generic Functions
Generic functions use type parameters (usually T) to work with any type while maintaining type safety.
Simple Generic Functions
// Identity function - returns what you pass in
function identity<T>(arg: T): T {
return arg;
}
// TypeScript infers the type
const num = identity(42); // Type: number
const str = identity("hello"); // Type: string
const bool = identity(true); // Type: boolean
const obj = identity({ x: 10 }); // Type: { x: number }
// You can explicitly specify the type
const explicit = identity<string>("world"); // Type: string
// Array wrapper
function wrapInArray<T>(value: T): T[] {
return [value];
}
const nums = wrapInArray(5); // Type: number[]
const strs = wrapInArray("hello"); // Type: string[]
const objs = wrapInArray({ id: 1 }); // Type: { id: number }[]
console.log(nums); // [5]
console.log(strs); // ["hello"]
// Get last element
function getLast<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const lastNum = getLast([1, 2, 3]); // Type: number | undefined
const lastStr = getLast(["a", "b", "c"]); // Type: string | undefined
console.log(lastNum); // 3
console.log(lastStr); // "c"
// Swap function
function swap<T, U>(tuple: [T, U]): [U, T] {
return [tuple[1], tuple[0]];
}
const original: [string, number] = ["hello", 42];
const swapped = swap(original); // Type: [number, string]
console.log(original); // ["hello", 42]
console.log(swapped); // [42, "hello"]Generic Functions with Multiple Type Parameters
// Two type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair("name", "Yusuf"); // Type: [string, string]
const p2 = pair("age", 25); // Type: [string, number]
const p3 = pair(true, [1, 2, 3]); // Type: [boolean, number[]]
console.log(p1); // ["name", "Yusuf"]
console.log(p2); // ["age", 25]
// Map function
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const numbers = [1, 2, 3, 4, 5];
const doubled = map(numbers, (n) => n * 2);
// Type: number[]
const stringified = map(numbers, (n) => `Number: ${n}`);
// Type: string[]
const objects = map(numbers, (n) => ({ value: n }));
// Type: { value: number }[]
console.log(doubled); // [2, 4, 6, 8, 10]
console.log(stringified); // ["Number: 1", "Number: 2", ...]
console.log(objects); // [{ value: 1 }, { value: 2 }, ...]
// Merge objects
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const person = { name: "Yusuf", age: 25 };
const contact = { email: "yusuf@example.com", phone: "+234-803-123-4567" };
const fullProfile = merge(person, contact);
// Type: { name: string; age: number } & { email: string; phone: string }
console.log(fullProfile);
// { name: "Yusuf", age: 25, email: "yusuf@example.com", phone: "+234-803-123-4567" }
console.log(fullProfile.name); // "Yusuf"
console.log(fullProfile.email); // "yusuf@example.com"Generic Interfaces
Interfaces can also use generics to define reusable, type-safe structures.
// Generic interface
interface Container<T> {
value: T;
getValue(): T;
setValue(value: T): void;
}
// Using with different types
const numberContainer: Container<number> = {
value: 42,
getValue() {
return this.value;
},
setValue(value: number) {
this.value = value;
}
};
const stringContainer: Container<string> = {
value: "hello",
getValue() {
return this.value;
},
setValue(value: string) {
this.value = value;
}
};
console.log(numberContainer.getValue()); // 42
numberContainer.setValue(100);
console.log(numberContainer.getValue()); // 100
// Generic response interface
interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: Date;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
const userResponse: ApiResponse<User> = {
success: true,
data: {
id: 1,
name: "Yusuf Ibrahim",
email: "yusuf@example.com"
},
timestamp: new Date()
};
const productResponse: ApiResponse<Product> = {
success: true,
data: {
id: 101,
name: "Laptop",
price: 450000
},
timestamp: new Date()
};
console.log(userResponse.data.name); // "Yusuf Ibrahim"
console.log(productResponse.data.price); // 450000
// Generic with multiple type parameters
interface Pair<K, V> {
key: K;
value: V;
}
const stringNumber: Pair<string, number> = {
key: "age",
value: 25
};
const numberBoolean: Pair<number, boolean> = {
key: 1,
value: true
};
console.log(`${stringNumber.key}: ${stringNumber.value}`); // "age: 25"Generic Classes
Classes can use generics to create reusable, type-safe data structures and utilities.
// Generic class
class Box<T> {
private content: T;
constructor(value: T) {
this.content = value;
}
getValue(): T {
return this.content;
}
setValue(value: T): void {
this.content = value;
}
}
// Create boxes with different types
const numberBox = new Box<number>(42);
console.log(numberBox.getValue()); // 42
numberBox.setValue(100);
console.log(numberBox.getValue()); // 100
const stringBox = new Box<string>("hello");
console.log(stringBox.getValue()); // "hello"
stringBox.setValue("world");
console.log(stringBox.getValue()); // "world"
// Type inference works too
const autoBox = new Box(true); // TypeScript infers Box<boolean>
console.log(autoBox.getValue()); // true
// Generic stack
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3
console.log(numberStack.peek()); // 2
console.log(numberStack.size()); // 2
const stringStack = new Stack<string>();
stringStack.push("first");
stringStack.push("second");
console.log(stringStack.pop()); // "second"
console.log(stringStack.peek()); // "first"
// Generic key-value store
class KeyValueStore<K, V> {
private store = new Map<K, V>();
set(key: K, value: V): void {
this.store.set(key, value);
}
get(key: K): V | undefined {
return this.store.get(key);
}
has(key: K): boolean {
return this.store.has(key);
}
delete(key: K): boolean {
return this.store.delete(key);
}
getAll(): [K, V][] {
return Array.from(this.store.entries());
}
}
const userStore = new KeyValueStore<number, string>();
userStore.set(1, "Yusuf");
userStore.set(2, "Amina");
userStore.set(3, "Chidi");
console.log(userStore.get(1)); // "Yusuf"
console.log(userStore.has(2)); // true
console.log(userStore.getAll()); // [[1, "Yusuf"], [2, "Amina"], [3, "Chidi"]]Working with Generic Arrays
// Generic array utilities
function toArray<T>(...items: T[]): T[] {
return items;
}
const nums = toArray(1, 2, 3, 4, 5); // Type: number[]
const strs = toArray("a", "b", "c"); // Type: string[]
const mixed = toArray(1, "two", 3, "four"); // Type: (string | number)[]
console.log(nums); // [1, 2, 3, 4, 5]
console.log(mixed); // [1, "two", 3, "four"]
// Filter array
function filter<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const evens = filter(numbers, (n) => n % 2 === 0);
const greaterThanFive = filter(numbers, (n) => n > 5);
console.log(evens); // [2, 4, 6, 8, 10]
console.log(greaterThanFive); // [6, 7, 8, 9, 10]
// Find in array
function find<T>(arr: T[], predicate: (item: T) => boolean): T | undefined {
return arr.find(predicate);
}
const users = [
{ id: 1, name: "Yusuf", age: 25 },
{ id: 2, name: "Amina", age: 30 },
{ id: 3, name: "Chidi", age: 28 }
];
const user = find(users, (u) => u.id === 2);
console.log(user); // { id: 2, name: "Amina", age: 30 }
// Reduce array
function reduce<T, U>(
arr: T[],
reducer: (acc: U, item: T) => U,
initial: U
): U {
return arr.reduce(reducer, initial);
}
const sum = reduce(numbers, (acc, n) => acc + n, 0);
console.log(sum); // 55
const names = reduce(users, (acc, user) => [...acc, user.name], [] as string[]);
console.log(names); // ["Yusuf", "Amina", "Chidi"]
// Chunk array
function chunk<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const chunked = chunk(items, 3);
console.log(chunked); // [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
const words = ["apple", "banana", "cherry", "date", "elderberry"];
const wordChunks = chunk(words, 2);
console.log(wordChunks); // [["apple", "banana"], ["cherry", "date"], ["elderberry"]]Practical Examples
Example 1: Data Fetcher
// Generic data fetcher
class DataFetcher<T> {
private cache = new Map<string, T>();
async fetch(url: string): Promise<T> {
// Check cache first
if (this.cache.has(url)) {
console.log("Returning from cache");
return this.cache.get(url)!;
}
console.log("Fetching from API");
const response = await fetch(url);
const data = await response.json() as T;
// Cache the result
this.cache.set(url, data);
return data;
}
clearCache(): void {
this.cache.clear();
}
}
// User type
interface User {
id: number;
name: string;
email: string;
}
// Product type
interface Product {
id: number;
name: string;
price: number;
}
// Create typed fetchers
const userFetcher = new DataFetcher<User>();
const productFetcher = new DataFetcher<Product>();
// Use with full type safety
async function getUser(id: number): Promise<void> {
const user = await userFetcher.fetch(`/api/users/${id}`);
// TypeScript knows 'user' is User type
console.log(`User: ${user.name}`);
console.log(`Email: ${user.email}`);
}
async function getProduct(id: number): Promise<void> {
const product = await productFetcher.fetch(`/api/products/${id}`);
// TypeScript knows 'product' is Product type
console.log(`Product: ${product.name}`);
console.log(`Price: ₦${product.price}`);
}
// Usage
getUser(1);
getProduct(101);Example 2: Repository Pattern
// Generic repository for CRUD operations
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: T[] = [];
private nextId = 1;
create(item: Omit<T, "id">): T {
const newItem = { ...item, id: this.nextId++ } as T;
this.items.push(newItem);
return newItem;
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
findAll(): T[] {
return [...this.items];
}
update(id: number, updates: Partial<T>): T | undefined {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return undefined;
this.items[index] = { ...this.items[index], ...updates };
return this.items[index];
}
delete(id: number): boolean {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return false;
this.items.splice(index, 1);
return true;
}
count(): number {
return this.items.length;
}
}
// User entity
interface User extends Entity {
name: string;
email: string;
age: number;
}
// Product entity
interface Product extends Entity {
name: string;
price: number;
inStock: boolean;
}
// Create repositories
const userRepo = new Repository<User>();
const productRepo = new Repository<Product>();
// User operations
const user1 = userRepo.create({
name: "Yusuf Ibrahim",
email: "yusuf@example.com",
age: 25
});
const user2 = userRepo.create({
name: "Amina Hassan",
email: "amina@example.com",
age: 30
});
console.log(`Created user: ${user1.name}`);
console.log(`Total users: ${userRepo.count()}`);
userRepo.update(user1.id, { age: 26 });
const updatedUser = userRepo.findById(user1.id);
console.log(`Updated age: ${updatedUser?.age}`);
// Product operations
const product1 = productRepo.create({
name: "Laptop",
price: 450000,
inStock: true
});
const product2 = productRepo.create({
name: "Mouse",
price: 5000,
inStock: true
});
console.log(`Created product: ${product1.name}`);
console.log(`Total products: ${productRepo.count()}`);
const allProducts = productRepo.findAll();
allProducts.forEach(p => {
console.log(`${p.name}: ₦${p.price}`);
});Example 3: Event Emitter
// Generic event emitter
class EventEmitter<T> {
private listeners: Array<(data: T) => void> = [];
on(listener: (data: T) => void): void {
this.listeners.push(listener);
}
off(listener: (data: T) => void): void {
const index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
emit(data: T): void {
this.listeners.forEach(listener => listener(data));
}
once(listener: (data: T) => void): void {
const onceWrapper = (data: T) => {
listener(data);
this.off(onceWrapper);
};
this.on(onceWrapper);
}
}
// User login event
interface UserLoginEvent {
userId: number;
username: string;
timestamp: Date;
}
// Order created event
interface OrderCreatedEvent {
orderId: number;
customerId: number;
total: number;
}
// Create typed emitters
const loginEmitter = new EventEmitter<UserLoginEvent>();
const orderEmitter = new EventEmitter<OrderCreatedEvent>();
// User login listeners
loginEmitter.on((event) => {
console.log(`User ${event.username} logged in at ${event.timestamp}`);
});
loginEmitter.on((event) => {
console.log(`Logging user activity for user ${event.userId}`);
});
// Order created listeners
orderEmitter.on((event) => {
console.log(`Order #${event.orderId} created for ₦${event.total}`);
});
orderEmitter.once((event) => {
console.log(`First order notification: #${event.orderId}`);
});
// Emit events
loginEmitter.emit({
userId: 1,
username: "Yusuf",
timestamp: new Date()
});
orderEmitter.emit({
orderId: 1001,
customerId: 1,
total: 50000
});
orderEmitter.emit({
orderId: 1002,
customerId: 2,
total: 75000
});
// First order notification only appears onceTest Your Knowledge
Type Safety Check
Which generic function is correctly defined?
// Option A
function identity(arg: any): any {
return arg;
}
// Option B
function identity<T>(arg: T): T {
return arg;
}
// Option C
function identity<T>(arg: any): T {
return arg;
}
// Option D
function identity(arg: T): T {
return arg;
}Common TypeScript Error
// Function without generics - loses type information
function getFirstElement(arr: any[]): any {
return arr[0];
}
const numbers = [1, 2, 3];
const first = getFirstElement(numbers);
// Lost type information!
// TypeScript thinks 'first' is 'any'
first.toFixed(2); // No error but might crash
first.toUpperCase(); // No error but will crash
first.whatever(); // No error but will crash❌ No compile-time error, but runtime crashes possible because type is 'any'
What's Wrong?
Without generics, functions that work with different types must use 'any', which loses all type safety. TypeScript can't help you use the result correctly.
// Function with generics - preserves type information
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const numbers = [1, 2, 3];
const first = getFirstElement(numbers);
// Type preserved! TypeScript knows 'first' is 'number | undefined'
if (first !== undefined) {
first.toFixed(2); // ✅ Works - TypeScript knows it's a number
// first.toUpperCase(); // ❌ Error - TypeScript knows numbers don't have toUpperCase
}
const strings = ["hello", "world"];
const firstStr = getFirstElement(strings);
// Type is 'string | undefined'
if (firstStr !== undefined) {
firstStr.toUpperCase(); // ✅ Works - TypeScript knows it's a string
// firstStr.toFixed(2); // ❌ Error - TypeScript knows strings don't have toFixed
}Solution: Use generics to preserve type information
Best Practices
- Use meaningful type parameter names -
Tfor single type, descriptive names for multiple - Let TypeScript infer when possible - explicit types when needed
- Prefer generics over any - maintain type safety
- Use generics for reusable code - avoid duplication
- Keep generic functions simple - easy to understand
- Document complex generics with comments
- Consider default type parameters for flexibility
- Test with different types to ensure generics work
// ✅ Good: Let TypeScript infer
function wrap<T>(value: T): T[] {
return [value];
}
const nums = wrap(5); // Inferred as number[]
const strs = wrap("hello"); // Inferred as string[]
// ✅ Good: Explicit when needed
const explicit = wrap<number>(5); // Explicitly number[]
// ✅ Good: Descriptive names for multiple parameters
function mapValues<TInput, TOutput>(
items: TInput[],
mapper: (item: TInput) => TOutput
): TOutput[] {
return items.map(mapper);
}
// ✅ Good: Generic interface
interface Result<T> {
success: boolean;
data?: T;
error?: string;
}
// ✅ Good: Generic class
class Cache<T> {
private data = new Map<string, T>();
set(key: string, value: T): void {
this.data.set(key, value);
}
get(key: string): T | undefined {
return this.data.get(key);
}
}
// ❌ Avoid: Using 'any' instead of generics
function badWrap(value: any): any[] {
return [value]; // Loses type information
}
// ❌ Avoid: Too complex generics
function confusing<T, U, V, W, X, Y, Z>(/* too many params */): any {
// Hard to understand and maintain
}
// ✅ Better: Keep it simple
function simple<T>(value: T): T {
return value;
}Key Takeaways
- Generics create reusable code that works with multiple types
- Type parameters (
T) act like function parameters for types - Generics preserve type information, unlike
any - TypeScript can infer generic types from arguments
- Generic functions, interfaces, and classes are all supported
- Multiple type parameters enable complex generic patterns
- Generics work with arrays, objects, and custom types
- Use generics to avoid code duplication
- Always prefer generics over
anyfor type safety - Generics are fundamental to TypeScript's power
What's Next?
Excellent work! You've mastered the basics of generics and understand how to create reusable, type-safe code. Next, we'll explore Generic Constraints. You'll learn how to restrict generic types using extends, create bounded generics, and ensure your generic code works only with types that have specific properties. This gives you even more control over your generic types!
Get ready to master advanced generic patterns!