Generics#
Generics allow you to write flexible, reusable code that works with multiple types while maintaining type safety. Think of them as "type variables" or "type parameters".
Why Generics?#
Without generics:
// ❌ Not reusable - only works with numbers
function getFirstNumber(arr: number[]): number {
return arr[0];
}
// ❌ Loses type safety - could be anything
function getFirst(arr: any[]): any {
return arr[0];
}
With generics:
// ✅ Reusable AND type-safe
function getFirst<T>(arr: T[]): T {
return arr[0];
}
const firstNum = getFirst([1, 2, 3]); // Type: number
const firstStr = getFirst(["a", "b", "c"]); // Type: string
Basic Generic Functions#
Single Type Parameter#
function identity<T>(arg: T): T {
return arg;
}
const num = identity(42); // Type: number
const str = identity("hello"); // Type: string
const bool = identity(true); // Type: boolean
// Explicit type annotation (usually unnecessary)
const explicit = identity<string>("hello");
Working with Arrays#
function getLastItem<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
const lastNumber = getLastItem([1, 2, 3]); // number | undefined
const lastName = getLastItem(["a", "b", "c"]); // string | undefined
// Reversing an array
function reverseArray<T>(arr: T[]): T[] {
return arr.reverse();
}
const reversed = reverseArray([1, 2, 3, 4]); // number[]
Multiple Type Parameters#
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const numberAndString = pair(42, "hello"); // [number, string]
const boolAndNumber = pair(true, 100); // [boolean, number]
// Swapping values
function swap<T, U>(tuple: [T, U]): [U, T] {
return [tuple[1], tuple[0]];
}
const swapped = swap([42, "hello"]); // [string, number]
Generic Constraints#
Restrict what types can be used with a generic:
extends Constraint#
// Only allow types with a length property
function logLength<T extends { length: number }>(item: T): void {
console.log(`Length: ${item.length}`);
}
logLength("hello"); // ✅ string has length
logLength([1, 2, 3]); // ✅ array has length
logLength({ length: 10 }); // ✅ object with length
// logLength(42); // ❌ number doesn't have length
// Constraint with interface
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
const user = findById(users, 1); // { id: number; name: string } | undefined
keyof Constraint#
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Alice", age: 25, city: "NYC" };
const name = getProperty(person, "name"); // Type: string
const age = getProperty(person, "age"); // Type: number
// getProperty(person, "invalid"); // ❌ Error
// Setting a property
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
obj[key] = value;
}
setProperty(person, "age", 26); // ✅ OK
// setProperty(person, "age", "26"); // ❌ Error: string is not assignable to number
Generic Interfaces#
interface Container<T> {
value: T;
getValue(): T;
setValue(value: T): void;
}
const stringContainer: Container<string> = {
value: "hello",
getValue() {
return this.value;
},
setValue(value: string) {
this.value = value;
}
};
// Generic interface with multiple parameters
interface KeyValuePair<K, V> {
key: K;
value: V;
}
const pair1: KeyValuePair<string, number> = {
key: "age",
value: 25
};
const pair2: KeyValuePair<number, string> = {
key: 1,
value: "first"
};
Generic Classes#
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;
}
}
const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
console.log(numberQueue.dequeue()); // 1
const stringQueue = new Queue<string>();
stringQueue.enqueue("first");
stringQueue.enqueue("second");
console.log(stringQueue.peek()); // "first"
Generic Class with Constraints#
interface Comparable {
compareTo(other: this): number;
}
class SortedList<T extends Comparable> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
this.items.sort((a, b) => a.compareTo(b));
}
getAll(): T[] {
return [...this.items];
}
}
class Version implements Comparable {
constructor(
public major: number,
public minor: number,
public patch: number
) {}
compareTo(other: Version): number {
if (this.major !== other.major) return this.major - other.major;
if (this.minor !== other.minor) return this.minor - other.minor;
return this.patch - other.patch;
}
}
const versions = new SortedList<Version>();
versions.add(new Version(2, 0, 0));
versions.add(new Version(1, 5, 3));
versions.add(new Version(1, 6, 0));
console.log(versions.getAll()); // Sorted by version
Generic Type Aliases#
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function fetchUser(id: string): Result<User> {
if (id) {
return { success: true, data: { id, name: "Alice" } };
}
return { success: false, error: new Error("User not found") };
}
const result = fetchUser("123");
if (result.success) {
console.log(result.data); // User
} else {
console.error(result.error); // Error
}
// Generic wrapper type
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;
let name: Nullable<string> = null;
let age: Optional<number> = undefined;
let email: Maybe<string> = "test@example.com";
Conditional Types#
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Practical example: Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : T;
type NumArray = ArrayElement<number[]>; // number
type StrArray = ArrayElement<string[]>; // string
type NotArray = ArrayElement<boolean>; // boolean
// NonNullable implementation
type MyNonNullable<T> = T extends null | undefined ? never : T;
type C = MyNonNullable<string | null>; // string
type D = MyNonNullable<number | undefined>; // number
Mapped Types#
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties required
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Pick specific properties
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Usage
interface User {
id: number;
name: string;
email: string;
age: number;
}
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number; }
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string; }
Practical Examples#
Generic API Response#
interface ApiResponse<T> {
data: T;
status: number;
error: string | null;
timestamp: number;
}
interface PaginatedResponse<T> {
items: T[];
page: number;
pageSize: number;
total: number;
hasMore: boolean;
}
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(url);
const data = await response.json();
return {
data,
status: response.status,
error: null,
timestamp: Date.now()
};
} catch (error) {
return {
data: {} as T,
status: 500,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: Date.now()
};
}
}
// Usage
interface User {
id: number;
name: string;
}
const userResponse = await fetchData<User>("/api/user/1");
const usersResponse = await fetchData<PaginatedResponse<User>>("/api/users");
Generic Repository Pattern#
interface Entity {
id: number | string;
}
class Repository<T extends Entity> {
private items: Map<T["id"], T> = new Map();
create(item: T): T {
this.items.set(item.id, item);
return item;
}
findById(id: T["id"]): T | undefined {
return this.items.get(id);
}
update(id: T["id"], updates: Partial<T>): T | undefined {
const item = this.items.get(id);
if (item) {
const updated = { ...item, ...updates };
this.items.set(id, updated);
return updated;
}
return undefined;
}
delete(id: T["id"]): boolean {
return this.items.delete(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
findWhere(predicate: (item: T) => boolean): T[] {
return this.findAll().filter(predicate);
}
}
// Usage
interface User extends Entity {
id: number;
name: string;
email: string;
}
const userRepo = new Repository<User>();
userRepo.create({ id: 1, name: "Alice", email: "alice@example.com" });
userRepo.create({ id: 2, name: "Bob", email: "bob@example.com" });
const user = userRepo.findById(1);
const allUsers = userRepo.findAll();
const activeUsers = userRepo.findWhere(u => u.email.includes("example"));
Generic State Machine#
type State<T> = {
data: T;
status: "idle" | "loading" | "success" | "error";
error: string | null;
};
type Action<T> =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: T }
| { type: "FETCH_ERROR"; error: string }
| { type: "RESET" };
function createReducer<T>(initialData: T) {
return function reducer(state: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case "FETCH_START":
return { ...state, status: "loading", error: null };
case "FETCH_SUCCESS":
return {
data: action.payload,
status: "success",
error: null
};
case "FETCH_ERROR":
return { ...state, status: "error", error: action.error };
case "RESET":
return { data: initialData, status: "idle", error: null };
default:
return state;
}
};
}
// Usage
interface User {
id: number;
name: string;
}
const userReducer = createReducer<User>({ id: 0, name: "" });
let state: State<User> = {
data: { id: 0, name: "" },
status: "idle",
error: null
};
state = userReducer(state, { type: "FETCH_START" });
state = userReducer(state, {
type: "FETCH_SUCCESS",
payload: { id: 1, name: "Alice" }
});
Generic Event Emitter#
type EventMap = Record<string, any>;
type EventHandler<T = any> = (event: T) => void;
class TypedEventEmitter<Events extends EventMap> {
private listeners = new Map<keyof Events, EventHandler[]>();
on<K extends keyof Events>(
event: K,
handler: EventHandler<Events[K]>
): () => void {
const handlers = this.listeners.get(event) || [];
handlers.push(handler);
this.listeners.set(event, handlers);
return () => this.off(event, handler);
}
off<K extends keyof Events>(
event: K,
handler: EventHandler<Events[K]>
): void {
const handlers = this.listeners.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}
}
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
const handlers = this.listeners.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
}
}
}
// Usage
interface AppEvents {
userLogin: { userId: string; timestamp: number };
userLogout: { userId: string };
dataUpdate: { collection: string; id: string };
error: { message: string; code: number };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Type-safe event listeners
emitter.on("userLogin", (data) => {
console.log(`User ${data.userId} logged in`); // data is typed correctly!
});
emitter.on("error", (data) => {
console.error(`Error ${data.code}: ${data.message}`);
});
// Type-safe event emission
emitter.emit("userLogin", { userId: "123", timestamp: Date.now() });
// emitter.emit("userLogin", { invalid: true }); // ❌ Error
Best Practices#
-
✅ Use descriptive type parameter names
// ✅ Clear intent function map<Input, Output>(arr: Input[], fn: (item: Input) => Output): Output[] // ❌ Less clear function map<T, U>(arr: T[], fn: (item: T) => U): U[] -
✅ Add constraints when needed
function sort<T extends { id: number }>(items: T[]): T[] -
✅ Use type inference when possible
const result = identity(42); // No need for identity<number>(42) -
✅ Provide default type parameters for common cases
interface Response<T = any> { data: T; } -
❌ Don't over-genericize
// ❌ Too generic function process<T, U, V, W>(a: T, b: U, c: V): W { /* ... */ } // ✅ Simpler when possible function process(a: string, b: number): boolean { /* ... */ }
Next Steps#
- Learn about Utility Types built with generics
- Explore Type Guards & Narrowing for runtime safety
- Master Advanced Types patterns