Generics
Introduction
Generics allow you to create reusable, type-safe components that work with multiple types while maintaining full type checking. They're one of TypeScript's most powerful features for writing flexible, maintainable code.
Think of generics as type variables - placeholders for types that get filled in when you use the function or class. This lets you write code once that works safely with many different types.
The Problem: Code Duplication
Without generics (repetitive):
function identifyString(arg: string): string {
return arg;
}
function identifyNumber(arg: number): number {
return arg;
}
function identifyBoolean(arg: boolean): boolean {
return arg;
}
// Need separate function for each type!
With any (loses type safety):
function identify(arg: any): any {
return arg;
}
const result = identify("hello");
// TypeScript doesn't know result is a string
// No autocomplete, no type checking
With generics (flexible and type-safe):
function identify<T>(arg: T): T {
return arg;
}
const str = identify<string>("hello"); // str is string
const num = identify<number>(42); // num is number
const bool = identify<boolean>(true); // bool is boolean
const auto = identify("inferred"); // Type inferred as string
// One function, full type safety!
Why Generics Matter
- Reusability - Write code once, use with many types
- Type Safety - Maintain full type checking
- Flexibility - Work with any type, not just
any - Better IDE Support - Autocomplete and error detection
- Self-Documenting - Types show exactly what's expected
Concepts
Basic Generic Function
The <T> syntax creates a type parameter:
function identity<T>(arg: T): T {
return arg;
}
// Usage with explicit type
const result1 = identity<string>("hello");
// Usage with type inference
const result2 = identity(42); // T is inferred as number
// Works with any type
const result3 = identity({ name: "Alice" });
const result4 = identity([1, 2, 3]);
Generic with Multiple Type Parameters
You can have multiple type parameters:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair<string, number>("age", 25);
const p2 = pair("name", "Alice"); // Types inferred
function map<T, U>(value: T, fn: (arg: T) => U): U {
return fn(value);
}
const length = map("hello", (s) => s.length); // 5
const upper = map("hello", (s) => s.toUpperCase()); // "HELLO"
Generic Interfaces
interface Box<T> {
value: T;
}
const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };
interface Pair<T, U> {
first: T;
second: U;
}
const pair: Pair<string, number> = {
first: "age",
second: 25
};
Generic Classes
class Container<T> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
setValue(value: T): void {
this.value = value;
}
}
const stringContainer = new Container<string>("hello");
console.log(stringContainer.getValue()); // "hello"
const numberContainer = new Container<number>(42);
console.log(numberContainer.getValue()); // 42
Generic Constraints
Use extends to constrain what types are allowed:
// T must have a length property
function logLength<T extends { length: number }>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength("hello"); // ✅ string has length
logLength([1, 2, 3]); // ✅ array has length
logLength({ length: 10 }); // ✅ object has length
// logLength(42); // ❌ number doesn't have length
// T must extend a specific type
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ name: "Alice" }, { age: 25 });
console.log(merged.name); // "Alice"
console.log(merged.age); // 25
Generic Type Aliases
type Result<T> = {
success: boolean;
data?: T;
error?: string;
};
const successResult: Result<string> = {
success: true,
data: "Operation completed"
};
const errorResult: Result<number> = {
success: false,
error: "Failed to fetch data"
};
type Callback<T> = (value: T) => void;
type Transform<T, U> = (value: T) => U;
const stringCallback: Callback<string> = (s) => console.log(s);
const toNumber: Transform<string, number> = (s) => parseInt(s);
Generic Arrays
// Built-in generic array type
const numbers: Array<number> = [1, 2, 3];
const strings: Array<string> = ["a", "b", "c"];
// Creating a generic array function
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const first1 = firstElement([1, 2, 3]); // number | undefined
const first2 = firstElement(["a", "b", "c"]); // string | undefined
function lastElement<T>(arr: Array<T>): T | undefined {
return arr[arr.length - 1];
}
keyof and Generic Constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = {
name: "Alice",
age: 25,
email: "alice@example.com"
};
const name = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
// const invalid = getProperty(person, "invalid"); // ❌ Error
Examples
Generic Stack Implementation
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;
}
clear(): void {
this.items = [];
}
toArray(): T[] {
return [...this.items];
}
}
// Usage
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3
console.log(numberStack.peek()); // 2
const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.toArray()); // ["hello", "world"]
Generic Repository Pattern
interface Entity {
id: number;
}
class Repository<T extends Entity> {
private items: Map<number, T> = new Map();
private nextId = 1;
create(item: Omit<T, "id">): T {
const newItem = { ...item, id: this.nextId++ } as T;
this.items.set(newItem.id, newItem);
return newItem;
}
findById(id: number): T | undefined {
return this.items.get(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
update(id: number, updates: Partial<T>): T | undefined {
const item = this.items.get(id);
if (!item) return undefined;
const updated = { ...item, ...updates };
this.items.set(id, updated);
return updated;
}
delete(id: number): boolean {
return this.items.delete(id);
}
count(): number {
return this.items.size;
}
}
// Usage
interface User extends Entity {
name: string;
email: string;
}
const userRepo = new Repository<User>();
const user1 = userRepo.create({ name: "Alice", email: "alice@example.com" });
const user2 = userRepo.create({ name: "Bob", email: "bob@example.com" });
console.log(userRepo.findById(1)); // User with id 1
console.log(userRepo.findAll()); // All users
userRepo.update(1, { name: "Alice Smith" });
Generic API Response Handler
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
timestamp: Date;
}
class ApiClient {
async get<T>(url: string): Promise<ApiResponse<T>> {
try {
// Simulate API call
await this.delay(1000);
const data = {} as T; // Simulated data
return {
success: true,
data,
timestamp: new Date()
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date()
};
}
}
async post<T, U>(url: string, body: U): Promise<ApiResponse<T>> {
try {
await this.delay(1000);
const data = {} as T;
return {
success: true,
data,
timestamp: new Date()
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: new Date()
};
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Usage
interface User {
id: number;
name: string;
}
interface CreateUserRequest {
name: string;
email: string;
}
const client = new ApiClient();
async function example() {
const response = await client.get<User>("/users/1");
if (response.success && response.data) {
console.log(response.data.name);
}
const createResponse = await client.post<User, CreateUserRequest>(
"/users",
{ name: "Alice", email: "alice@example.com" }
);
}
Generic Event Emitter
type EventCallback<T> = (data: T) => void;
class TypedEventEmitter<TEvents extends Record<string, any>> {
private events: Map<keyof TEvents, EventCallback<any>[]> = new Map();
on<K extends keyof TEvents>(
event: K,
callback: EventCallback<TEvents[K]>
): void {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(callback);
}
off<K extends keyof TEvents>(
event: K,
callback: EventCallback<TEvents[K]>
): void {
const callbacks = this.events.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
const callbacks = this.events.get(event);
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
}
// Define event types
interface AppEvents {
userLogin: { username: string; timestamp: Date };
userLogout: { username: string };
dataUpdate: { id: number; newValue: string };
}
// Usage
const emitter = new TypedEventEmitter<AppEvents>();
emitter.on("userLogin", (data) => {
console.log(`${data.username} logged in at ${data.timestamp}`);
});
emitter.on("dataUpdate", (data) => {
console.log(`Data ${data.id} updated to ${data.newValue}`);
});
emitter.emit("userLogin", {
username: "Alice",
timestamp: new Date()
});
emitter.emit("dataUpdate", {
id: 1,
newValue: "Updated"
});
// Type safety - this would be an error:
// emitter.emit("userLogin", { username: 123 }); // ❌ username must be string
Challenges
1. Generic Swap Function
- Create a function that swaps two values
- Return them in reverse order
- Works with any type
💡 Show Answer
function swap<T, U>(a: T, b: U): [U, T] {
return [b, a];
}
const [x, y] = swap(1, "hello");
console.log(x); // "hello" (string)
console.log(y); // 1 (number)
const [first, second] = swap(true, 42);
console.log(first); // 42 (number)
console.log(second); // true (boolean)
2. Generic Array Wrapper
- Create a class that wraps an array
- Add methods: add, remove, get, size
- Type-safe for any element type
💡 Show Answer
class ArrayList<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
remove(index: number): T | undefined {
return this.items.splice(index, 1)[0];
}
get(index: number): T | undefined {
return this.items[index];
}
size(): number {
return this.items.length;
}
toArray(): T[] {
return [...this.items];
}
clear(): void {
this.items = [];
}
}
const numbers = new ArrayList<number>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
console.log(numbers.get(1)); // 2
console.log(numbers.size()); // 3
const strings = new ArrayList<string>();
strings.add("hello");
strings.add("world");
console.log(strings.toArray()); // ["hello", "world"]
3. Generic Filter Function
- Implement a filter function
- Takes array and predicate
- Returns filtered array with same type
💡 Show Answer
function filter<T>(
arr: T[],
predicate: (item: T) => boolean
): T[] {
const result: T[] = [];
for (const item of arr) {
if (predicate(item)) {
result.push(item);
}
}
return result;
}
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const evens = filter(numbers, (n) => n % 2 === 0);
console.log(evens); // [2, 4, 6, 8, 10]
const words = ["apple", "banana", "cherry", "date"];
const longWords = filter(words, (w) => w.length > 5);
console.log(longWords); // ["banana", "cherry"]
4. Generic Map Function
- Implement a map function
- Transform array elements
- Return new array with different type
💡 Show Answer
function map<T, U>(
arr: T[],
transform: (item: T) => U
): U[] {
const result: U[] = [];
for (const item of arr) {
result.push(transform(item));
}
return result;
}
const numbers = [1, 2, 3, 4, 5];
const doubled = map(numbers, (n) => n * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
const strings = map(numbers, (n) => `Number: ${n}`);
console.log(strings); // ["Number: 1", "Number: 2", ...]
const objects = map(numbers, (n) => ({ value: n }));
console.log(objects); // [{ value: 1 }, { value: 2 }, ...]
5. Generic Pair Class
- Create a Pair class with two values
- Values can be different types
- Add getters and swap method
💡 Show Answer
class Pair<T, U> {
constructor(
private first: T,
private second: U
) {}
getFirst(): T {
return this.first;
}
getSecond(): U {
return this.second;
}
setFirst(value: T): void {
this.first = value;
}
setSecond(value: U): void {
this.second = value;
}
swap(): Pair<U, T> {
return new Pair(this.second, this.first);
}
toString(): string {
return `(${this.first}, ${this.second})`;
}
}
const pair1 = new Pair("name", 25);
console.log(pair1.toString()); // "(name, 25)"
console.log(pair1.getFirst()); // "name"
const swapped = pair1.swap();
console.log(swapped.toString()); // "(25, name)"
6. Generic findFirst Function
- Find first element matching predicate
- Return element or undefined
- Type-safe
💡 Show Answer
function findFirst<T>(
arr: T[],
predicate: (item: T) => boolean
): T | undefined {
for (const item of arr) {
if (predicate(item)) {
return item;
}
}
return undefined;
}
const numbers = [1, 3, 5, 6, 8, 10];
const firstEven = findFirst(numbers, (n) => n % 2 === 0);
console.log(firstEven); // 6
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" }
];
const user = findFirst(users, (u) => u.name === "Bob");
console.log(user); // { id: 2, name: "Bob" }
7. Generic Queue
- Implement a queue data structure
- Methods: enqueue, dequeue, peek
- Generic element type
💡 Show Answer
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T | undefined {
return this.items.shift();
}
peek(): T | undefined {
return this.items[0];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
clear(): void {
this.items = [];
}
toArray(): T[] {
return [...this.items];
}
}
const queue = new Queue `<number>`();
queue.enqueue(1);
queue.enqueue(2);
queue.enqueue(3);
console.log(queue.dequeue()); // 1
console.log(queue.peek()); // 2
console.log(queue.size()); // 2
8. Generic groupBy Function
- Group array elements by a key
- Return object with grouped items
- Key can be any property
💡 Show Answer
function groupBy<T, K extends string | number>(
arr: T[],
keyFn: (item: T) => K
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of arr) {
const key = keyFn(item);
if (!result[key]) {
result[key] = [];
}
result[key].push(item);
}
return result;
}
interface Person {
name: string;
age: number;
city: string;
}
const people: Person[] = [
{ name: "Alice", age: 25, city: "London" },
{ name: "Bob", age: 30, city: "Paris" },
{ name: "Charlie", age: 25, city: "London" },
{ name: "David", age: 30, city: "London" }
];
const byAge = groupBy(people, (p) => p.age);
console.log(byAge);
// {
// 25: [{ name: "Alice", ... }, { name: "Charlie", ... }],
// 30: [{ name: "Bob", ... }, { name: "David", ... }]
// }
const byCity = groupBy(people, (p) => p.city);
console.log(byCity);
// {
// London: [{ name: "Alice", ... }, { name: "Charlie", ... }, { name: "David", ... }],
// Paris: [{ name: "Bob", ... }]
// }
9. Generic Result Type
- Create Result<T, E> type for success/error
- Methods: isSuccess, isError, unwrap
- Handle both cases safely
💡 Show Answer
class Result<T, E = Error> {
private constructor(
private readonly success: boolean,
private readonly value?: T,
private readonly error?: E
) {}
static ok<T, E = Error>(value: T): Result<T, E> {
return new Result<T, E>(true, value, undefined);
}
static err<T, E = Error>(error: E): Result<T, E> {
return new Result<T, E>(false, undefined, error);
}
isSuccess(): boolean {
return this.success;
}
isError(): boolean {
return !this.success;
}
unwrap(): T {
if (!this.success) {
throw new Error("Called unwrap on error result");
}
return this.value!;
}
unwrapOr(defaultValue: T): T {
return this.success ? this.value! : defaultValue;
}
match<U>(handlers: {
ok: (value: T) => U;
err: (error: E) => U;
}): U {
return this.success
? handlers.ok(this.value!)
: handlers.err(this.error!);
}
}
// Usage
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return Result.err("Division by zero");
}
return Result.ok(a / b);
}
const result1 = divide(10, 2);
if (result1.isSuccess()) {
console.log("Result:", result1.unwrap()); // 5
}
const result2 = divide(10, 0);
if (result2.isError()) {
console.log("Error:", result2.unwrapOr(0)); // 0
}
const message = result2.match({
ok: (value) => `Success: ${value}`,
err: (error) => `Error: ${error}`
});
console.log(message); // "Error: Division by zero"
10. Generic Cache
- Implement a caching system
- Store and retrieve values by key
- Support TTL (time to live)
💡 Show Answer
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
class Cache<T> {
private cache: Map<string, CacheEntry<T>> = new Map();
set(key: string, value: T, ttlMs: number = 60000): void {
const expiresAt = Date.now() + ttlMs;
this.cache.set(key, { value, expiresAt });
}
get(key: string): T | undefined {
const entry = this.cache.get(key);
if (!entry) {
return undefined;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
has(key: string): boolean {
return this.get(key) !== undefined;
}
delete(key: string): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
size(): number {
// Clean expired entries
this.cleanExpired();
return this.cache.size;
}
private cleanExpired(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
this.cache.delete(key);
}
}
}
}
// Usage
interface User {
id: number;
name: string;
}
const userCache = new Cache<User>();
userCache.set("user1", { id: 1, name: "Alice" }, 5000);
userCache.set("user2", { id: 2, name: "Bob" }, 10000);
console.log(userCache.get("user1")); // { id: 1, name: "Alice" }
setTimeout(() => {
console.log(userCache.get("user1")); // undefined (expired)
}, 6000);
const stringCache = new Cache<string>();
stringCache.set("greeting", "Hello", 3000);
console.log(stringCache.get("greeting")); // "Hello"
Summary
In this chapter, you learned:
✅ What generics are and why they're useful
✅ Generic functions with type parameters
✅ Generic interfaces and type aliases
✅ Generic classes
✅ Generic constraints with extends
✅ Multiple type parameters
✅ keyof and mapped types
✅ Built-in generic types
Key Takeaways:
- Generics enable reusable, type-safe code
<T>creates a type parameter- Type inference often works automatically
- Use constraints to limit allowed types
- Generics work with functions, classes, and interfaces
- Essential for building flexible libraries
Common Patterns:
- Generic containers: Stack, Queue, List
- Generic utilities: map, filter, reduce
- Generic result/response types
- Generic repository pattern
- Generic event systems
Best Practices:
- Use descriptive type parameter names for clarity
- Start with
T, useU,Vfor additional parameters - Use meaningful names like
TKey,TValuewhen appropriate - Add constraints when needed
- Let TypeScript infer types when possible
- Don't overuse generics - keep it simple
Built-in Generic Types:
Array<T>Promise<T>Map<K, V>Set<T>Record<K, V>Partial<T>Required<T>Readonly<T>
In the next chapter, we'll explore Union & Intersection Types and learn more advanced type composition!
Continue to Chapter 14: Union & Intersection Types →