Skip to main content

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, use U, V for additional parameters
  • Use meaningful names like TKey, TValue when 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 →