Understanding Generics
Generics in TypeScript provide a mechanism to create reusable components that work with a variety of types rather than a single one. By allowing types to be specified later—when the functon or class is instantiated—generics offer a robust way to handle dynamic data while maintaining strict type safety.
The primary advantages of utilizing generics include:
- Compile-Time Safety: Errors are caught during the build process before the code runs.
- Code Reusability: A single logic block can handle strings, numbers, or custom objects.
- Clear Intent: Type signatures explicitly define the relationship between input and output.
Consider a basic wrapper function that returns whatever is passed to it:
function echo<T>(input: T): T {
return input;
}
// Explicitly defining the type as string
const text = echo<string>("TypeScript");
// Allowing type inference (automatically detected as number)
const count = echo(42);
TypeScript vs. Java Generics
While both TypeScript and Java implement generics to enhance type safety, their underlying mechanics differ significantly due to the nature of their type systems.
| Feature | TypeScript | Java |
|---|---|---|
| Type System | Structural Typing (duck typing) | Nominal Typing |
| Type Erasure | Generics exist only at compile time; runtime info is stripped. | Generics are erased during compilation; runtime types are not available. |
| Applicability | Functions, interfaces, classes, type aliases, and enums. | Primarily classes and methods. |
| Tuple Support | Native support for generic fixed-length arrays. | Generally requires custom classes or array manipulation. |
Fundamental Usage Patterns
1. Generic Functions
Functions can accept arguments of any type while preserving the relationship between the argument and the return type. Below is a function that combines two items into an array.
function createPair<T>(first: T, second: T): T[] {
return [first, second];
}
const numberCombo = createPair(10, 20); // inferred as number[]
const stringCombo = createPair("a", "b"); // inferred as string[]
2. Generic Classes
Classes can use generics to define the types of their properties or method return values. Here is a simple data storage class.
class DataStorage<T> {
private item: T;
constructor(value: T) {
this.item = value;
}
getItem(): T {
return this.item;
}
setItem(newValue: T): void {
this.item = newValue;
}
}
const userStorage = new DataStorage<{ id: number }>({ id: 1 });
const configStorage = new DataStorage<string>("debug-mode");
3. Generic Interfaces
Interfaces define the shape of data, and generics allow that shape to adapt to different types.
interface Container<V> {
id: number;
payload: V;
}
const logEntry: Container<string> = {
id: 101,
payload: "System started"
};
const dataEntry: Container<number[]> = {
id: 102,
payload: [10, 20, 30]
};
Implementing Constraints
Sometimes a generic needs to guarantee that a specific property or method exists on the passed type. This is achieved using the extends keyword.
1. Interface Constraints
We can restrict a type parameter to ensure it has specific properties, such as a unique identifier.
interface Identifiable {
uuid: string;
}
function logId<T extends Identifiable>(entity: T): void {
console.log(`Entity ID: ${entity.uuid}`);
}
const product = { uuid: "p-123", name: "Laptop" };
logId(product); // Valid
// const simpleNum = 5;
// logId(simpleNum); // Error: number does not have uuid
2. Keyof Constraints
To ensure an argument is actually a key of an object, we use keyof T.
function retrieveValue<T, K extends keyof T>(source: T, key: K): T[K] {
return source[key];
}
const settings = { theme: "dark", volume: 80 };
retrieveValue(settings, "theme"); // OK
// retrieveValue(settings, "brightness"); // Error: "brightness" is not a key
3. Constructor Constraints
This pattern allows a generic function to create instances of a class.
function factory<T>(type: { new (): T }): T {
return new type();
}
class Service {
start() {
console.log("Service running");
}
}
const instance = factory(Service);
instance.start();
Advanced Generic Techniques
1. Utility Types
TypeScript includes built-in generics to transform types efficiently.
interface Product {
id: number;
name: string;
price: number;
}
// Make all properties optional
type DraftProduct = Partial<Product>;
// Make all properties readonly
type ReadonlyProduct = Readonly<Product>;
// Pick only specific keys
type ProductSummary = Pick<Product, "name" | "price">;
2. Conditional Types
These allow types to be selected based on a condition, similar to a ternary operator.
type Message<T> = T extends string ? "Text" : "Data";
type Test1 = Message<string>; // "Text"
type Test2 = Message<number>; // "Data"
3. Mapped Types
Mapped types iterate over keys to create new types.
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type ReadonlyObj = {
readonly x: number;
};
// MutableObj.x can be modified
type MutableObj = Mutable<ReadonlyObj>;
Real-World Applications
1. Implementing a Queue
Generics are ideal for data structures that must store any specific type consistently.
class Queue<T> {
private elements: T[] = [];
enqueue(item: T): void {
this.elements.push(item);
}
dequeue(): T | undefined {
return this.elements.shift();
}
get size(): number {
return this.elements.length;
}
}
const orderQueue = new Queue<string>();
orderQueue.enqueue("Order #1");
orderQueue.enqueue("Order #2");
console.log(orderQueue.dequeue());
2. Handling API Responses
A generic wrapper can standardize API calls across different endpoints.
interface HttpResult<T> {
status: number;
payload: T;
timestamp: number;
}
async function fetchResource<T>(url: string): Promise<HttpResult<T>> {
const response = await fetch(url);
const data = await response.json();
return {
status: response.status,
payload: data,
timestamp: Date.now()
};
}
interface User {
email: string;
}
fetchResource<User>("/api/me").then(res => {
console.log(res.payload.email);
});
3. Functon Overloading
Combining overloads with generics improves developer experience for versatile functions.
function combine<T>(a: T, b: T): T;
function combine<T>(items: T[]): T[];
function combine<T>(arg1: T | T[], arg2?: T): T | T[] {
if (Array.isArray(arg1)) {
return arg1;
}
return [arg1, arg2!];
}
const resultA = combine(1, 2); // number[]
const resultB = combine([1, 2, 3]); // number[]
Best Practices
- Avoid Redundancy: Do not use generics if the type is always going to be the same or if
anysuffices. - Semantic Naming: Use descriptive names for parameters (e.g.,
TKey,TValue) instead of single letters when complex types are involved. - Constrain Hard: Apply constraints early to prevent invalid types from entering the logic.
- Leverage Built-ins: Prefer standard utility types like
PickorOmitover manual mapping where possible. - Use Type Guards: Combine generics with type guards to refine types inside conditional blocks.
Summary
Generics are a cornerstone of TypeScript, enabling developers to write abstract, flexible, and type-safe code. By mastering basic functions, constraints, utility types, and mapped types, engineers can design systems that scale with complexity without sacrificing reliability. Advanced usage involving conditional types and inference further pushes the boundaries of what can be achieved at the type level.