Skip to main content

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").

typescript
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:

  1. Using the any type (which loses type safety)
  2. Creating separate functions for each data type (which leads to code duplication)

Let's compare:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
// 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:

typescript
// 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

  1. Create a generic reverse function that reverses arrays of any type.
  2. Implement a generic KeyValuePair class that stores a key-value pair with configurable types.
  3. Write a generic pick function that takes an object and a list of keys and returns a new object with just those keys.
  4. Create a generic state management hook for a React component.
  5. Implement a generic cache utility that can store and retrieve values of any type by key.

Additional Resources

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! :)