TypeScript Advanced Types — Generics Deep Dive
Advertisement
Generics are TypeScript's most powerful feature for writing reusable code. They allow you to write functions, classes, and interfaces that work with any type while maintaining type safety. Understanding generics deeply unlocks the ability to build sophisticated, reusable libraries and frameworks.
Understanding Generics
A generic is a type parameter that becomes concrete when used. Think of it like a template:
// Without generics - loses type information
function identity(value: any): any {
return value;
}
const result = identity("hello");
// result type is 'any' - we lose information
// With generics - preserves type information
function identity<T>(value: T): T {
return value;
}
const result = identity("hello"); // type is 'string'
Generic Constraints
Constraints limit what types a generic can accept:
// Without constraints - T could be anything
function getProperty<T>(obj: T, key: string): any {
return obj[key]; // Error: property access on unknown
}
// With constraints - T must have string properties
function getProperty<T extends { [key: string]: any }>(
obj: T,
key: string
): any {
return obj[key];
}
// More practical constraint
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength(42); // Error: number has no length
- Understanding Generics
- Generic Constraints
- Multiple Type Parameters
- Generic Defaults
- Generic Classes
- Generic Interfaces
- Conditional Generics
- Advanced Generic Patterns
- Builder Pattern
- Mapped Types with Generics
- Generic Utility Functions
- Performance Considerations
- Common Pitfalls
- FAQ
Multiple Type Parameters
function map<T, U>(array: T[], callback: (item: T) => U): U[] {
return array.map(callback);
}
const numbers = [1, 2, 3];
const strings = map(numbers, n => n.toString());
// strings: string[]
// Constrained multiple parameters
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ a: 1 }, { b: 2 });
// result: { a: number } & { b: number }
Generic Defaults
// Generic with default type
type Container<T = string> = {
value: T;
};
const stringContainer: Container = { value: "hello" };
const numberContainer: Container<number> = { value: 42 };
// Function with default generic
function create<T = object>(value?: T): T {
return value || ({} as T);
}
const obj = create(); // type is 'object'
const num = create<number>(5); // type is 'number'
Generic Classes
class Repository<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getById(index: number): T | undefined {
return this.items[index];
}
getAll(): T[] {
return this.items;
}
filter(predicate: (item: T) => boolean): T[] {
return this.items.filter(predicate);
}
}
interface User {
id: number;
name: string;
}
const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: "Alice" });
// All methods now work with User type
const users = userRepo.getAll();
// users: User[]
Generic Interfaces
interface ApiResponse<T> {
status: number;
data: T;
timestamp: Date;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
}
// Usage
async function fetchUsers(): Promise<ApiResponse<User[]>> {
const response = await fetch("/api/users");
return response.json();
}
async function searchPosts(
query: string
): Promise<PaginatedResponse<Post>> {
const response = await fetch(`/api/posts?q=${query}`);
return response.json();
}
Conditional Generics
// Type conditional on generic parameter
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<number>; // false
// Practical example - extract array type
type ArrayElement<T> = T extends (infer E)[] ? E : T;
type StringArray = ArrayElement<string[]>; // string
type SingleValue = ArrayElement<42>; // 42
// Real-world use case
function flatten<T>(
value: T extends any[] ? T : T[]
): T extends any[] ? T : T[] {
return Array.isArray(value) ? value : [value];
}
Advanced Generic Patterns
Builder Pattern
class QueryBuilder<T> {
private conditions: string[] = [];
private limit_value?: number;
where(condition: string): QueryBuilder<T> {
this.conditions.push(condition);
return this;
}
limit(n: number): QueryBuilder<T> {
this.limit_value = n;
return this;
}
build(): string {
let query = `SELECT * FROM ${this.getTableName()}`;
if (this.conditions.length) {
query += ` WHERE ${this.conditions.join(" AND ")}`;
}
if (this.limit_value) {
query += ` LIMIT ${this.limit_value}`;
}
return query;
}
private getTableName(): string {
return "table";
}
}
interface Product {
id: number;
name: string;
price: number;
}
const query = new QueryBuilder<Product>()
.where("price > 100")
.limit(10)
.build();
Mapped Types with Generics
// Create getters for all properties
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// }
Generic Utility Functions
// Type-safe cache
function createCache<T>(): {
get(key: string): T | undefined;
set(key: string, value: T): void;
} {
const store = new Map<string, T>();
return {
get: (key) => store.get(key),
set: (key, value) => store.set(key, value),
};
}
const userCache = createCache<User>();
userCache.set("user-1", { id: 1, name: "Alice" });
const user = userCache.get("user-1"); // User | undefined
// Compose functions with generics
function compose<A, B, C>(
f: (a: A) => B,
g: (b: B) => C
): (a: A) => C {
return (a) => g(f(a));
}
const toString = (n: number) => n.toString();
const toUpperCase = (s: string) => s.toUpperCase();
const composed = compose(toString, toUpperCase);
const result = composed(42); // "42"
Performance Considerations
- Avoid excessive constraints - simple generics compile faster
- Use default types - reduces instantiation overhead
- Cache instantiated types - TypeScript caches generated types
Common Pitfalls
- Over-constraining: Make constraints only as restrictive as needed
- Forgetting type inference: Let TypeScript infer when possible
- Mixing
anywith generics: Defeats the purpose of type safety
FAQ
Q: When should I use generics vs overloads? A: Generics preserve type relationships across parameters. Use overloads when you have different return types for different inputs. Generics are usually better for reusable logic.
Q: Can generics have circular constraints? A: Yes, but carefully. T extends { next: T } is valid and useful for linked structures, but use sparingly to avoid infinite recursion.
Q: How do generics affect runtime performance? A: Generics are compile-time only. They completely disappear in JavaScript, so there's zero runtime cost.
Mastering generics transforms you from writing TypeScript code to writing TypeScript properly. Generics are how you build frameworks, libraries, and systems that scale. With constraints, defaults, and conditional types, you can express any type relationship your code needs.
Advertisement