TypeScript Generics
Generics in TypeScript enable you to create reusable components that can work with multiple types while preserving type safety. They provide a way to make components flexible without sacrificing the benefits of strong typing, allowing you to write code that is both reusable and type-safe.
Generics are particularly powerful when creating libraries, utility functions, and data structures that need to work with various types while maintaining compile-time type checking.
Basic Generics
Generic functions and classes use type parameters to work with multiple types.
// Generic function
function identity<T>(arg: T): T {
return arg;
}
// Usage with explicit type
let stringResult = identity<string>("hello");
let numberResult = identity<number>(42);
// Usage with type inference
let inferredString = identity("world"); // TypeScript infers T as string
let inferredNumber = identity(123); // TypeScript infers T as number
// Generic function with multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
let stringNumberPair = pair("age", 25); // [string, number]
let booleanArrayPair = pair(true, [1, 2, 3]); // [boolean, number[]]
// Generic array function
function getFirstElement<T>(array: T[]): T | undefined {
return array[0];
}
let firstString = getFirstElement(["a", "b", "c"]); // string | undefined
let firstNumber = getFirstElement([1, 2, 3]); // number | undefined
Generic Interfaces
Interfaces can use generic type parameters to define flexible contracts.
// Generic interface
interface Container<T> {
value: T;
getValue(): T;
setValue(value: T): void;
}
// Implementing generic interface
class Box<T> implements Container<T> {
constructor(private _value: T) {}
get value(): T {
return this._value;
}
getValue(): T {
return this._value;
}
setValue(value: T): void {
this._value = value;
}
}
// Usage
let stringBox = new Box<string>("hello");
let numberBox = new Box<number>(42);
// Generic interface with multiple type parameters
interface KeyValuePair<K, V> {
key: K;
value: V;
}
interface Dictionary<K, V> {
add(key: K, value: V): void;
get(key: K): V | undefined;
has(key: K): boolean;
remove(key: K): boolean;
keys(): K[];
values(): V[];
}
// Implementation
class SimpleDictionary<K, V> implements Dictionary<K, V> {
private items: KeyValuePair<K, V>[] = [];
add(key: K, value: V): void {
this.items.push({ key, value });
}
get(key: K): V | undefined {
const item = this.items.find(item => item.key === key);
return item?.value;
}
has(key: K): boolean {
return this.items.some(item => item.key === key);
}
remove(key: K): boolean {
const index = this.items.findIndex(item => item.key === key);
if (index !== -1) {
this.items.splice(index, 1);
return true;
}
return false;
}
keys(): K[] {
return this.items.map(item => item.key);
}
values(): V[] {
return this.items.map(item => item.value);
}
}
Generic Constraints
Constraints limit generic types to specific shapes or capabilities.
// Basic constraint using extends
interface Lengthwise {
length: number;
}
function logLength<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // Now we can access .length
return arg;
}
logLength("hello"); // OK: string has length
logLength([1, 2, 3]); // OK: array has length
logLength({ length: 10, value: 3 }); // OK: object has length
// logLength(42); // Error: number doesn't have length
// Using keyof constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
let person = { name: "Alice", age: 30, city: "New York" };
let name = getProperty(person, "name"); // string
let age = getProperty(person, "age"); // number
// let invalid = getProperty(person, "invalid"); // Error: invalid key
// Multiple constraints
interface Drawable {
draw(): void;
}
interface Timestamped {
timestamp: Date;
}
function processDrawableWithTime<T extends Drawable & Timestamped>(item: T): T {
item.draw();
console.log("Processed at:", item.timestamp);
return item;
}
// Conditional constraints
function create<T extends string | number>(value: T): T extends string ? string[] : number[] {
if (typeof value === "string") {
return [value] as any;
} else {
return [value] as any;
}
}
let stringArray = create("hello"); // string[]
let numberArray = create(42); // number[]
Generic Classes
Classes can be generic and work with different types while maintaining type safety.
// Generic class
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
let stringStack = new Stack<string>();
stringStack.push("first");
stringStack.push("second");
console.log(stringStack.pop()); // "second"
let numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.peek()); // 2
// Generic class with constraints
abstract class Repository<T extends { id: string | number }> {
protected items: T[] = [];
add(item: T): void {
this.items.push(item);
}
findById(id: string | number): T | undefined {
return this.items.find(item => item.id === id);
}
remove(id: string | number): boolean {
const index = this.items.findIndex(item => item.id === id);
if (index !== -1) {
this.items.splice(index, 1);
return true;
}
return false;
}
getAll(): T[] {
return [...this.items];
}
abstract validate(item: T): boolean;
}
// Concrete implementation
interface User {
id: number;
name: string;
email: string;
}
class UserRepository extends Repository<User> {
validate(user: User): boolean {
return user.name.length > 0 && user.email.includes("@");
}
findByEmail(email: string): User | undefined {
return this.items.find(user => user.email === email);
}
}
Advanced Generic Patterns
Complex generic patterns for advanced type manipulation.
// Mapped types with generics
type Partial<T> = {
[P in keyof T]?: T[P];
};
type Required<T> = {
[P in keyof T]-?: T[P];
};
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
// Recursive generic types
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// Generic utility for nested object manipulation
type NestedKeyOf<ObjectType extends object> = {
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
: `${Key}`;
}[keyof ObjectType & (string | number)];
// Function overloading with generics
function map<T, U>(array: T[], fn: (item: T) => U): U[];
function map<T, U>(array: T[], fn: (item: T, index: number) => U): U[];
function map<T, U>(array: T[], fn: (item: T, index?: number) => U): U[] {
return array.map(fn);
}
// Generic type guards
function isArray<T>(value: T | T[]): value is T[] {
return Array.isArray(value);
}
function hasProperty<K extends string>(
obj: object,
key: K
): obj is Record<K, unknown> {
return key in obj;
}
// Example usage
interface Person {
name: string;
address: {
street: string;
city: string;
country: string;
};
}
type PersonKeys = NestedKeyOf<Person>;
// "name" | "address" | "address.street" | "address.city" | "address.country"
const person: DeepReadonly<Person> = {
name: "John",
address: {
street: "123 Main St",
city: "Boston",
country: "USA"
}
};
// person.name = "Jane"; // Error: readonly
// person.address.city = "NYC"; // Error: readonly
Generic Utility Functions
Common patterns for creating reusable generic utilities.
// Array utilities
function chunk<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
function groupBy<T, K extends string | number>(
array: T[],
keyFn: (item: T) => K
): Record<K, T[]> {
return array.reduce((groups, item) => {
const key = keyFn(item);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
}, {} as Record<K, T[]>);
}
function unique<T>(array: T[], keyFn?: (item: T) => any): T[] {
if (!keyFn) {
return [...new Set(array)];
}
const seen = new Set();
return array.filter(item => {
const key = keyFn(item);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
// Object utilities
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach(key => {
if (key in obj) {
result[key] = obj[key];
}
});
return result;
}
function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result = { ...obj };
keys.forEach(key => {
delete result[key];
});
return result;
}
// Promise utilities
async function asyncMap<T, U>(
array: T[],
asyncFn: (item: T) => Promise<U>
): Promise<U[]> {
return Promise.all(array.map(asyncFn));
}
async function asyncFilter<T>(
array: T[],
asyncPredicate: (item: T) => Promise<boolean>
): Promise<T[]> {
const results = await Promise.all(array.map(asyncPredicate));
return array.filter((_, index) => results[index]);
}
// Usage examples
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const chunked = chunk(numbers, 3); // [[1,2,3], [4,5,6], [7,8,9]]
const users = [
{ name: "Alice", department: "Engineering" },
{ name: "Bob", department: "Marketing" },
{ name: "Charlie", department: "Engineering" }
];
const grouped = groupBy(users, user => user.department);
// { Engineering: [Alice, Charlie], Marketing: [Bob] }
const uniqueUsers = unique(users, user => user.department);
// [Alice, Bob] (one from each department)
Generic Error Handling
Generic patterns for robust error handling and result types.
// Result type for error handling
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Generic async operation wrapper
async function tryAsync<T>(
operation: () => Promise<T>
): Promise<Result<T>> {
try {
const data = await operation();
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
// Option type for nullable values
type Option<T> =
| { type: "some"; value: T }
| { type: "none" };
function some<T>(value: T): Option<T> {
return { type: "some", value };
}
function none<T>(): Option<T> {
return { type: "none" };
}
function mapOption<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
return option.type === "some" ? some(fn(option.value)) : none();
}
// Usage
async function fetchUserData(id: number): Promise<Result<User>> {
return tryAsync(async () => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
return response.json();
});
}
const userResult = await fetchUserData(1);
if (userResult.success) {
console.log(userResult.data.name); // Safe to access
} else {
console.error(userResult.error.message);
}
Generics in TypeScript provide a powerful way to create flexible, reusable, and type-safe code. They enable you to write functions, classes, and interfaces that work with multiple types while preserving compile-time type checking and providing excellent IntelliSense support.