TypeScript Generics
Introduction
Have you ever wanted to write a function that can work with any data type, but still maintain type safety? That's exactly what TypeScript generics are designed to do!
Generics allow you to create reusable components that can work with a variety of data types rather than a single one, without losing type information. They enable you to define functions, classes, and interfaces that can adapt to the data types provided by the caller rather than committing to a specific type in advance.
Think of generics as a way to tell TypeScript: "This function/class/interface will work with a type, but I'll tell you which type later when I use it."
Understanding Generics
Basic Syntax
The generic type parameter is enclosed in angle brackets <>
and typically uses a single uppercase letter (by convention, often T
for "Type").
function identity<T>(arg: T): T {
return arg;
}
// Using the function
let output1 = identity<string>("Hello, TypeScript!");
let output2 = identity(42); // Type inference - TypeScript infers the type as number
In this example, identity
is a generic function that works with any type. When we call it, we can either explicitly specify the type (<string>
) or let TypeScript infer it from the argument.
Why Use Generics?
Without generics, we'd have to choose between:
- Using the
any
type (which loses type safety) - Creating separate functions for each data type (which leads to code duplication)
Let's compare:
// Using 'any' - not type-safe
function identityAny(arg: any): any {
return arg;
}
const a = identityAny(42); // a has type 'any'
a.toFixed(); // No error, but might fail at runtime if a is not a number
// Using generics - type-safe
function identityGeneric<T>(arg: T): T {
return arg;
}
const b = identityGeneric(42); // b has type 'number'
b.toFixed(); // Safe and works as expected
Working with Generic Functions
Multiple Type Parameters
You can define multiple type parameters when needed:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const pairResult = pair<string, number>("key", 42);
// pairResult has type [string, number]
Generic Constraints
Sometimes you need to restrict the types that can be used with your generic function. You can do this with constraints:
interface HasLength {
length: number;
}
function printLength<T extends HasLength>(arg: T): number {
console.log(`Length: ${arg.length}`);
return arg.length;
}
printLength("Hello"); // Works - strings have length
printLength([1, 2, 3]); // Works - arrays have length
printLength({ length: 10, value: 3 }); // Works - object has length property
// printLength(123); // Error - numbers don't have a length property
In this example, T extends HasLength
means that the type parameter must have a length
property.
Generic Interfaces and Classes
Generic Interfaces
Interfaces can also use generics:
interface Box<T> {
value: T;
}
const stringBox: Box<string> = { value: "Hello" };
const numberBox: Box<number> = { value: 42 };
Generic Classes
Classes can leverage generics as well:
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];
}
size(): number {
return this.items.length;
}
}
const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
console.log(numberQueue.dequeue()); // 1
console.log(numberQueue.size()); // 1
const stringQueue = new Queue<string>();
stringQueue.enqueue("hello");
console.log(stringQueue.peek()); // "hello"
Real-World Applications
Generic React Components
If you're using React with TypeScript, generics are incredibly useful for creating reusable components:
// A generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, renderItem }: ListProps<T>) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
// Later in a component:
// <List<User>
// items={users}
// renderItem={(user) => <span>{user.name}</span>}
// />
API Response Handling
Generics are perfect for typing API responses:
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json() as Promise<T>;
}
interface Todo {
id: number;
title: string;
completed: boolean;
}
// Usage
const fetchTodos = async () => {
const todos = await fetchData<Todo[]>('https://api.example.com/todos');
todos.forEach(todo => {
console.log(todo.title); // TypeScript knows todo has a title property
});
};
Generic State Management
For state management libraries or custom hooks:
function createStore<State, Actions>(
initialState: State,
reducers: {
[K in keyof Actions]: (state: State, payload: Actions[K]) => State;
}
) {
let state = initialState;
return {
getState: () => state,
dispatch: <K extends keyof Actions>(
actionType: K,
payload: Actions[K]
) => {
state = reducers[actionType](state, payload);
return state;
}
};
}
// Usage
interface CounterState {
count: number;
}
interface CounterActions {
increment: number;
decrement: number;
reset: void;
}
const counterStore = createStore<CounterState, CounterActions>(
{ count: 0 },
{
increment: (state, payload) => ({ count: state.count + payload }),
decrement: (state, payload) => ({ count: state.count - payload }),
reset: (state) => ({ count: 0 })
}
);
counterStore.dispatch('increment', 5);
console.log(counterStore.getState().count); // 5
Advanced Generic Patterns
Default Type Parameters
You can provide default types for your generic parameters:
interface RequestOptions<TData = any> {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: TData;
}
const options1: RequestOptions = {
url: '/api/users',
method: 'GET'
};
interface User {
name: string;
email: string;
}
const options2: RequestOptions<User> = {
url: '/api/users',
method: 'POST',
data: { name: "John", email: "[email protected]" }
};
Conditional Types
TypeScript allows you to create types that depend on conditions:
type NonNullable<T> = T extends null | undefined ? never : T;
// Usage
type A = NonNullable<string | null>; // string
type B = NonNullable<number | undefined>; // number
Mapped Types with Generics
Combining mapped types with generics is powerful:
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K];
};
interface User {
id: number;
name: string;
email: string;
}
// All properties are now optional
type PartialUser = Partial<User>;
const user: PartialUser = { name: "Alice" }; // Valid
Common Gotchas and Best Practices
Don't Overuse Generics
Generics add complexity to your code. Use them when you need type safety across multiple types, not just for the sake of using them.
Naming Conventions
For simple cases, single letter names like T
, U
, V
are traditional. For more complex cases, descriptive names can be better:
// Single letters for simple cases
function swap<T, U>(tuple: [T, U]): [U, T] {
return [tuple[1], tuple[0]];
}
// Descriptive names for complex cases
function createKeyValuePair<TKey extends string | number, TValue>(
key: TKey,
value: TValue
): Record<TKey, TValue> {
return { [key]: value } as Record<TKey, TValue>;
}
Type Inference
Let TypeScript infer types when possible for cleaner code:
// No need for explicit type arguments here
const result = identity(42); // TypeScript infers T as number
// Only provide explicit types when necessary
const emptyArray = identity<string[]>([]); // Need to specify since empty array doesn't give enough info
Summary
TypeScript generics provide a powerful way to create reusable, type-safe components that work with a variety of data types. By understanding and using generics, you can:
- Write functions, classes, and interfaces that work with multiple types
- Maintain type safety without code duplication
- Create flexible APIs with strong type guarantees
- Build reusable components with type constraints
Generics might seem complex at first, but they are an essential tool in your TypeScript toolbox that will help you write more maintainable and robust code.
Exercises
- Create a generic
reverse
function that reverses arrays of any type. - Implement a generic
KeyValuePair
class that stores a key-value pair with configurable types. - Write a generic
pick
function that takes an object and a list of keys and returns a new object with just those keys. - Create a generic state management hook for a React component.
- Implement a generic cache utility that can store and retrieve values of any type by key.
Additional Resources
- TypeScript Handbook: Generics
- TypeScript Deep Dive: Generics
- Advanced TypeScript: Generic Mapped Types
Remember, the best way to master generics is through practice. Start incorporating them into your code, and you'll soon find yourself reaching for them when appropriate.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)