Skip to main content

Chapter 4: Generics

Authored by syscook.dev

What are Generics in TypeScript?

Generics in TypeScript are a way to create reusable components that can work with multiple types while maintaining type safety. They allow you to write code that is flexible and reusable without sacrificing type checking.

Key Concepts:

  • Type Parameters: Placeholders for types that are specified when using the generic
  • Generic Functions: Functions that can work with different types
  • Generic Classes: Classes that can work with different types
  • Generic Interfaces: Interfaces that can work with different types
  • Type Constraints: Limitations on what types can be used with generics

Why Use Generics?

1. Type Safety with Flexibility

Generics provide type safety while allowing code to work with different types.

// Without generics - loses type information
function getFirstItem(items: any[]): any {
return items[0];
}

// With generics - maintains type information
function getFirstItem<T>(items: T[]): T {
return items[0];
}

// Usage
const numbers = [1, 2, 3, 4, 5];
const strings = ["hello", "world", "typescript"];

const firstNumber = getFirstItem(numbers); // Type: number
const firstString = getFirstItem(strings); // Type: string

console.log("First number:", firstNumber);
console.log("First string:", firstString);

Output:

First number: 1
First string: hello

2. Code Reusability

Write once, use with multiple types.

// Generic function for creating arrays
function createArray<T>(length: number, value: T): T[] {
return Array(length).fill(value);
}

// Usage with different types
const numberArray = createArray<number>(5, 0);
const stringArray = createArray<string>(3, "default");
const booleanArray = createArray<boolean>(2, true);

console.log("Number array:", numberArray);
console.log("String array:", stringArray);
console.log("Boolean array:", booleanArray);

Output:

Number array: [0, 0, 0, 0, 0]
String array: ['default', 'default', 'default']
Boolean array: [true, true]

How to Use Generics?

1. Generic Functions

Basic Generic Functions

// Simple generic function
function identity<T>(arg: T): T {
return arg;
}

// Usage
const stringIdentity = identity<string>("Hello");
const numberIdentity = identity<number>(42);
const booleanIdentity = identity<boolean>(true);

console.log("String:", stringIdentity);
console.log("Number:", numberIdentity);
console.log("Boolean:", booleanIdentity);

// Type inference - TypeScript can infer the type
const inferredString = identity("TypeScript"); // Type: string
const inferredNumber = identity(100); // Type: number

console.log("Inferred string:", inferredString);
console.log("Inferred number:", inferredNumber);

Output:

String: Hello
Number: 42
Boolean: true
Inferred string: TypeScript
Inferred number: 100

Multiple Type Parameters

// Function with multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}

// Usage
const stringNumberPair = pair<string, number>("age", 25);
const numberBooleanPair = pair<number, boolean>(42, true);
const stringStringPair = pair("hello", "world");

console.log("String-Number pair:", stringNumberPair);
console.log("Number-Boolean pair:", numberBooleanPair);
console.log("String-String pair:", stringStringPair);

// Generic function with constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

interface Person {
name: string;
age: number;
email: string;
}

const person: Person = {
name: "Alice",
age: 30,
email: "[email protected]"
};

const name = getProperty(person, "name"); // Type: string
const age = getProperty(person, "age"); // Type: number
const email = getProperty(person, "email"); // Type: string

console.log("Name:", name);
console.log("Age:", age);
console.log("Email:", email);

Output:

String-Number pair: ['age', 25]
Number-Boolean pair: [42, true]
String-String pair: ['hello', 'world']
Name: Alice
Age: 30
Email: [email protected]

2. Generic Classes

Basic Generic Classes

// Generic class
class Container<T> {
private items: T[] = [];

add(item: T): void {
this.items.push(item);
}

get(index: number): T | undefined {
return this.items[index];
}

getAll(): T[] {
return [...this.items];
}

size(): number {
return this.items.length;
}
}

// Usage with different types
const stringContainer = new Container<string>();
stringContainer.add("Hello");
stringContainer.add("World");
stringContainer.add("TypeScript");

const numberContainer = new Container<number>();
numberContainer.add(1);
numberContainer.add(2);
numberContainer.add(3);

console.log("String container:", stringContainer.getAll());
console.log("Number container:", numberContainer.getAll());
console.log("String container size:", stringContainer.size());

Output:

String container: ['Hello', 'World', 'TypeScript']
Number container: [1, 2, 3]
String container size: 3

Generic Classes with Constraints

// Generic class with constraints
interface Comparable<T> {
compareTo(other: T): number;
}

class SortedContainer<T extends Comparable<T>> {
private items: T[] = [];

add(item: T): void {
this.items.push(item);
this.items.sort((a, b) => a.compareTo(b));
}

getAll(): T[] {
return [...this.items];
}
}

// Implementation of Comparable
class NumberWrapper implements Comparable<NumberWrapper> {
constructor(public value: number) {}

compareTo(other: NumberWrapper): number {
return this.value - other.value;
}
}

// Usage
const sortedContainer = new SortedContainer<NumberWrapper>();
sortedContainer.add(new NumberWrapper(5));
sortedContainer.add(new NumberWrapper(1));
sortedContainer.add(new NumberWrapper(3));

const sortedNumbers = sortedContainer.getAll().map(n => n.value);
console.log("Sorted numbers:", sortedNumbers);

Output:

Sorted numbers: [1, 3, 5]

3. Generic Interfaces

Basic Generic Interfaces

// Generic interface
interface Repository<T> {
findById(id: number): T | null;
save(entity: T): T;
delete(id: number): boolean;
findAll(): T[];
}

// Implementation
class UserRepository implements Repository<User> {
private users: User[] = [];
private nextId: number = 1;

findById(id: number): User | null {
return this.users.find(user => user.id === id) || null;
}

save(entity: User): User {
if (entity.id === 0) {
entity.id = this.nextId++;
}
this.users.push(entity);
return entity;
}

delete(id: number): boolean {
const index = this.users.findIndex(user => user.id === id);
if (index !== -1) {
this.users.splice(index, 1);
return true;
}
return false;
}

findAll(): User[] {
return [...this.users];
}
}

interface User {
id: number;
name: string;
email: string;
}

// Usage
const userRepo = new UserRepository();
const user1 = userRepo.save({ id: 0, name: "Alice", email: "[email protected]" });
const user2 = userRepo.save({ id: 0, name: "Bob", email: "[email protected]" });

console.log("All users:", userRepo.findAll());
console.log("User by ID:", userRepo.findById(1));

Output:

All users: [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' }
]
User by ID: { id: 1, name: 'Alice', email: '[email protected]' }

4. Type Constraints

Basic Constraints

// Constraint with extends
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}

// Usage
const stringLength = getLength("Hello World");
const arrayLength = getLength([1, 2, 3, 4, 5]);

console.log("String length:", stringLength);
console.log("Array length:", arrayLength);

// Constraint with keyof
function getPropertyValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

interface Product {
id: number;
name: string;
price: number;
inStock: boolean;
}

const product: Product = {
id: 1,
name: "Laptop",
price: 999.99,
inStock: true
};

const productName = getPropertyValue(product, "name");
const productPrice = getPropertyValue(product, "price");

console.log("Product name:", productName);
console.log("Product price:", productPrice);

Output:

String length: 11
Array length: 5
Product name: Laptop
Product price: 999.99

Advanced Constraints

// Multiple constraints
interface Serializable {
serialize(): string;
}

interface Deserializable {
deserialize(data: string): void;
}

// Function with multiple constraints
function processData<T extends Serializable & Deserializable>(item: T): T {
const serialized = item.serialize();
console.log("Serialized:", serialized);

const newItem = {} as T;
newItem.deserialize(serialized);
return newItem;
}

// Implementation
class User implements Serializable, Deserializable {
constructor(public name: string, public age: number) {}

serialize(): string {
return JSON.stringify({ name: this.name, age: this.age });
}

deserialize(data: string): void {
const parsed = JSON.parse(data);
this.name = parsed.name;
this.age = parsed.age;
}
}

// Usage
const user = new User("John", 25);
const processedUser = processData(user);
console.log("Processed user:", processedUser);

Output:

Serialized: {"name":"John","age":25}
Processed user: User { name: 'John', age: 25 }

5. Generic Utility Types

Built-in Generic Types

// Partial<T> - makes all properties optional
interface User {
id: number;
name: string;
email: string;
age: number;
}

type PartialUser = Partial<User>;
const partialUser: PartialUser = { name: "Alice" }; // All properties optional

// Required<T> - makes all properties required
type RequiredUser = Required<PartialUser>;
const requiredUser: RequiredUser = {
id: 1,
name: "Alice",
email: "[email protected]",
age: 25
};

// Pick<T, K> - pick specific properties
type UserBasicInfo = Pick<User, 'id' | 'name'>;
const basicInfo: UserBasicInfo = { id: 1, name: "Alice" };

// Omit<T, K> - omit specific properties
type UserWithoutId = Omit<User, 'id'>;
const userWithoutId: UserWithoutId = {
name: "Alice",
email: "[email protected]",
age: 25
};

console.log("Partial user:", partialUser);
console.log("Required user:", requiredUser);
console.log("Basic info:", basicInfo);
console.log("User without ID:", userWithoutId);

Output:

Partial user: { name: 'Alice' }
Required user: { id: 1, name: 'Alice', email: '[email protected]', age: 25 }
Basic info: { id: 1, name: 'Alice' }
User without ID: { name: 'Alice', email: '[email protected]', age: 25 }

Custom Generic Utility Types

// Custom utility types
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type NonNullable<T> = T extends null | undefined ? never : T;
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// Usage
interface Config {
apiUrl: string;
timeout: number;
retries: number;
debug: boolean;
}

type OptionalConfig = Optional<Config, 'timeout' | 'retries'>;
const config: OptionalConfig = {
apiUrl: "https://api.example.com",
debug: true
// timeout and retries are optional
};

type CleanString = NonNullable<string | null | undefined>;
const cleanString: CleanString = "Hello"; // Cannot be null or undefined

type ReadonlyConfig = DeepReadonly<Config>;
const readonlyConfig: ReadonlyConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
debug: false
};

// readonlyConfig.apiUrl = "new-url"; // Error: Cannot assign to 'apiUrl' because it is a read-only property

console.log("Optional config:", config);
console.log("Clean string:", cleanString);
console.log("Readonly config:", readonlyConfig);

Output:

Optional config: { apiUrl: 'https://api.example.com', debug: true }
Clean string: Hello
Readonly config: { apiUrl: 'https://api.example.com', timeout: 5000, retries: 3, debug: false }

Practical Examples

1. Generic API Client

// Generic API client
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

class ApiClient {
private baseUrl: string;

constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}

async get<T>(endpoint: string): Promise<ApiResponse<T>> {
// Simulate API call
return {
data: {} as T,
status: 200,
message: "Success"
};
}

async post<T, U>(endpoint: string, data: U): Promise<ApiResponse<T>> {
// Simulate API call
return {
data: {} as T,
status: 201,
message: "Created"
};
}
}

// Usage
interface User {
id: number;
name: string;
email: string;
}

interface Product {
id: number;
title: string;
price: number;
}

const apiClient = new ApiClient("https://api.example.com");

// Type-safe API calls
async function fetchUser(id: number): Promise<User> {
const response = await apiClient.get<User>(`/users/${id}`);
return response.data;
}

async function createProduct(productData: Omit<Product, 'id'>): Promise<Product> {
const response = await apiClient.post<Product, Omit<Product, 'id'>>("/products", productData);
return response.data;
}

// Simulate API calls
fetchUser(1).then(user => {
console.log("Fetched user:", user);
});

createProduct({ title: "Laptop", price: 999 }).then(product => {
console.log("Created product:", product);
});

Output:

Fetched user: {}
Created product: {}

2. Generic Event System

// Generic event system
type EventHandler<T> = (data: T) => void;

class EventEmitter<T extends Record<string, any>> {
private events: Map<keyof T, EventHandler<T[keyof T]>[]> = new Map();

on<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(handler as EventHandler<T[keyof T]>);
}

emit<K extends keyof T>(event: K, data: T[K]): void {
const handlers = this.events.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
}
}

off<K extends keyof T>(event: K, handler: EventHandler<T[K]>): void {
const handlers = this.events.get(event);
if (handlers) {
const index = handlers.indexOf(handler as EventHandler<T[keyof T]>);
if (index !== -1) {
handlers.splice(index, 1);
}
}
}
}

// Define event types
interface AppEvents {
userLogin: { userId: number; username: string };
userLogout: { userId: number };
productAdded: { productId: number; productName: string };
}

// Usage
const eventEmitter = new EventEmitter<AppEvents>();

// Register event handlers
eventEmitter.on('userLogin', (data) => {
console.log(`User ${data.username} (ID: ${data.userId}) logged in`);
});

eventEmitter.on('userLogout', (data) => {
console.log(`User ID ${data.userId} logged out`);
});

eventEmitter.on('productAdded', (data) => {
console.log(`Product "${data.productName}" (ID: ${data.productId}) added`);
});

// Emit events
eventEmitter.emit('userLogin', { userId: 1, username: 'alice' });
eventEmitter.emit('productAdded', { productId: 101, productName: 'Laptop' });
eventEmitter.emit('userLogout', { userId: 1 });

Output:

User alice (ID: 1) logged in
Product "Laptop" (ID: 101) added
User ID 1 logged out

Best Practices

1. Use Descriptive Type Parameter Names

// Good: Descriptive names
function processUserData<UserData, ProcessedData>(
data: UserData
): ProcessedData {
// Implementation
return {} as ProcessedData;
}

// Avoid: Single letters without context
function process<T, U>(data: T): U {
return {} as U;
}

2. Use Constraints When Appropriate

// Good: Use constraints to limit types
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

// Good: Use constraints for required methods
function serialize<T extends { toJSON(): string }>(item: T): string {
return item.toJSON();
}

3. Prefer Type Inference When Possible

// Good: Let TypeScript infer types
const numbers = [1, 2, 3, 4, 5];
const firstNumber = getFirstItem(numbers); // Type inferred as number

// Only specify types when necessary
const explicitNumber = getFirstItem<number>(numbers);

4. Use Generic Utility Types

// Good: Use built-in utility types
type PartialUser = Partial<User>;
type RequiredUser = Required<PartialUser>;
type UserBasicInfo = Pick<User, 'id' | 'name'>;

Common Pitfalls and Solutions

1. Generic Type Inference Issues

// ❌ Problem: Type inference fails
function createArray<T>(length: number, value: T): T[] {
return Array(length).fill(value);
}

const numbers = createArray(5, 0); // Type: number[]
const strings = createArray(3, ""); // Type: string[]

// ✅ Solution: Use explicit types when needed
const explicitNumbers = createArray<number>(5, 0);
const explicitStrings = createArray<string>(3, "");

2. Circular Type References

// ❌ Problem: Circular reference
type Circular<T> = T extends string ? Circular<T> : never;

// ✅ Solution: Use proper base cases
type SafeCircular<T> = T extends string ? string : never;

3. Complex Generic Constraints

// ❌ Problem: Too complex constraints
type Complex<T> = T extends string ? T extends number ? never : string : never;

// ✅ Solution: Break down into simpler types
type IsString<T> = T extends string ? true : false;
type IsNumber<T> = T extends number ? true : false;
type Simple<T> = IsString<T> extends true ? string : never;

Conclusion

Generics in TypeScript provide powerful tools for creating reusable, type-safe code. By understanding:

  • What generics are and their key concepts
  • Why they're essential for flexible, maintainable code
  • How to use generic functions, classes, interfaces, and constraints

You can create sophisticated type systems that work with multiple types while maintaining type safety. Generics enable building flexible libraries and frameworks that can adapt to different use cases without sacrificing compile-time type checking.

Next Steps

  • Practice creating generic functions and classes
  • Experiment with type constraints and utility types
  • Explore advanced generic patterns
  • Move on to Chapter 5: Modules & Namespaces

This tutorial is part of the TypeScript Mastery series by syscook.dev