Generics. What are they, what do they do, and why should you care?
Good questions! If you've ever found yourself repeating code for different data types, generics can help you streamline your development process. They are an incredibly powerful tool that allows developers to write flexible, reusable, and type-safe code.
In this guide, we'll explore the fundamentals of generics in TypeScript, why they are essential, and how you can effectively use them in your projects.
So whether you're a beginner TypeScript developer, or looking to refine your TypeScript skills, this guide will provide you with the knowledge you need to leverage generics effectively.
Let’s get into it…
Sidenote: If you're struggling to figure out generics and how to use them, want to learn more, or simply just want a deeper dive into TypeScript, then check out my complete course on Typescript here.
It takes you from a complete beginner to building real-world applications and being able to get hired, so there's something for every level of TypeScript user.
Check it out above, or watch the first videos here for free.
With that out of the way, let's get into arrays in TypeScript and how they work...
To understand the benefits of TypeScript generics, we need to remember one of the core principles of programming languages, which is DRY or ‘Don't Repeat Yourself’.
The reason this is a core principle is because duplication of code is problematic in software engineering. It increases complexity and raises the risk of introducing errors, and inevitably, the cost of maintenance.
Now, one of the beauties of TypeScript is its ability to catch mistakes while you're writing code, thanks to the static type-checking feature. However, when we want our function or class to handle multiple data types, we often end up repeating our code for each data type, violating the DRY principle.
Naughty I know.
For example
function logNumber(input: number) {
console.log(input);
}
function logString(input: string) {
console.log(input);
}
// ... and so on for each data type
The above code works, but it starts to become unmanageable after handling just a few different data types.
Why? Well for each new type, you have to write a new function, which not only bloats your codebase but also increases the risk of errors and makes maintenance more challenging.
This is where generics come into play.
By using generics, you can write a single function or class to handle multiple data types, thus adhering to the DRY principle and making your code more maintainable and scalable.
Let's break this down a little further...
Generics in TypeScript allow you to define a placeholder for a type that you can specify later. (The term 'generic' literally refers to the ability to create a ‘generic’ component that can work with any data type that is not tied to a specific one).
This flexibility enables you to write functions, interfaces, and classes that can operate with various data types without sacrificing type safety.
For example
Here you can see a basic generic function:
function log<T>(input: T): void {
console.log(input);
}
log("Hello, TypeScript Generics!"); // prints: Hello, TypeScript Generics!
log(2022); // prints: 2022
In the generic function above, <T>
is called a "generic type parameter" and acts as a placeholder for any data type. When you call the log function with different types of arguments, TypeScript ensures that the type is compatible with the function definition.
To help illustrate this, imagine that a distinct function is called for each input data type.
For example
If we call log
with a string like this:
log("hello");
It's conceptually similar to calling a non-generic function like this:
function logString(input: string) {
console.log(input);
}
This process happens at compile-time, so you end up with only one function that can work with multiple data types.
Using generics allows us to write more flexible and reusable code. For instance, our generic log
function can handle any type specified by <T>
.
This is great for functions like log
because it allows us to log anything. However, there are scenarios where we want to restrict the types our function can accept, and that's where generic constraints come in.
Using generics with type parameters that can be any type is powerful, but there are cases when we want to limit these types to ensure they meet certain criteria.
This is where generic constraints come in.
Constraints allow us to specify that a generic type parameter must conform to a particular structure or interface.
For example:
interface HasId {
id: number;
}
function getItemById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
If we break down the code above, we’re defining an interface HasId
with a single property id
of type number. The generic function getItemById
uses a type parameter T
that extends HasId
.
This means that T
can be any type that has an id
property of type number.
Now, let's see this function in action:
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
];
const user = getItemById(users, 2);
console.log(user); // Output: { id: 2, name: 'Bob' }
In this example, users
is an array of objects where each object has an id
and a name
. When we call getItemById
with this array and an id
value, it correctly finds and returns the object with the matching id
.
If we try to use a type that doesn't satisfy the HasId
interface, TypeScript will give us an error:
const error1 = getItemById("nope", 2); // Argument of type 'string' is not assignable to parameter of type 'HasId[]'
const error2 = getItemById(9, 2); // Argument of type 'number' is not assignable to parameter of type 'HasId[]'
This error occurs because the string "nope"
and the number 9
do not meet the requirement of having an id
property.
So, generic constraints ensure that our generic functions work only with types that match specific criteria, adding an extra layer of type safety and making our code more robust.
Sidenote: For more info on handling various data structures in TypeScript, check out my guide on TypeScript Union Types, which explores another great feature for managing multiple types effectively.
Just like functions, TypeScript interfaces can also leverage generics to enhance their flexibility and reusability.
What like? Well, using generics with interfaces allows you to define interfaces that work with a variety of data types, making your code more adaptable and type-safe.
For example
Let's start by defining a simple generic interface, like so:
interface Container<T> {
value: T;
}
Here, Container
is a generic interface with a type parameter T
, and can now be used to create containers for different types:
const stringContainer: Container<string> = { value: 'Hello' };
const numberContainer: Container<number> = { value: 42 };
With this setup, you can create Container
instances for any type without repeating code. This generic approach further reduces redundancy and increases the reusability of your code.
You can also define interfaces with multiple type parameters, which is useful when you need to specify more than one data type.
For example
interface Pair<K, V> {
key: K;
value: V;
}
The Pair
interface uses two type parameters, K
and V
, representing the types of the key and value, respectively. You can also create instances of this interface for different combinations of types, like so:
const numberStringPair: Pair<number, string> = { key: 1, value: 'one' };
const stringBooleanPair: Pair<string, boolean> = { key: 'isActive', value: true };
Using multiple type parameters adds even more flexibility, allowing you to define complex data structures that can adapt to various types.
For example
Consider a scenario where you are dealing with API responses. You could define a generic interface to represent the API response structure:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
Now, you can use this interface to type the responses for different data types:
interface User {
id: number;
name: string;
}
const userApiResponse: ApiResponse<User> = {
data: { id: 1, name: 'Alice' },
status: 200,
message: 'Success',
};
interface Product {
id: number;
title: string;
price: number;
}
const productApiResponse: ApiResponse<Product> = {
data: { id: 1, title: 'Laptop', price: 999 },
status: 200,
message: 'Success',
};
In this example, ApiResponse
is a generic interface that can handle responses for any type of data, making it a versatile and reusable component in your TypeScript codebase.
Generic classes allow you to define classes that can work with a variety of types, providing the same flexibility and reusability benefits as generic functions and interfaces.
The good news also is that writing generic classes in TypeScript is similar to writing generic interfaces.
For example
interface HasId {
id: number;
}
class Repository<T extends HasId> {
private items: T[] = [];
addItem(item: T): void {
this.items.push(item);
}
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}
}
Here the Repository
class is defined with a generic type parameter T
that extends the HasId
interface. This constraint ensures that the Repository
can only store items that have an id
property.
You can also create instances of the Repository
class for different types that satisfy the HasId
constraint:
interface User extends HasId {
name: string;
}
const userRepository = new Repository<User>();
userRepository.addItem({ id: 1, name: 'Alice' });
userRepository.addItem({ id: 2, name: 'Bob' });
console.log(userRepository.findById(1)); // Output: { id: 1, name: 'Alice' }
In this example, User
extends the HasId
interface, so it can be used with the Repository
class, while the userRepository
instance can add and retrieve users by their id
.
Inside the class, you can also use the generic type parameter T
anywhere that a type is allowed. This includes method parameters, return types, and property types.
Even better still, is the fact that you don't need to specify <T>
on the methods again because it is already mentioned on the class itself.
For example
Here's a more complex example that demonstrates using generics with class methods:
class DataManager<T> {
private data: T[] = [];
addData(item: T): void {
this.data.push(item);
}
getData(index: number): T | undefined {
return this.data[index];
}
updateData(index: number, item: T): void {
if (index >= 0 && index < this.data.length) {
this.data[index] = item;
}
}
removeData(index: number): void {
if (index >= 0 && index < this.data.length) {
this.data.splice(index, 1);
}
}
}
This DataManager
class can handle a list of any type of data, with methods to add, get, update, and remove items. While the use of generics ensures that all operations are type-safe and work consistently with the specified type T
.
Pretty handy right?
Another practical example of using generics in classes is a stack data structure.
For example
class Stack<T> {
private elements: T[] = [];
push(element: T): void {
this.elements.push(element);
}
pop(): T | undefined {
return this.elements.pop();
}
peek(): T | undefined {
return this.elements[this.elements.length - 1];
}
isEmpty(): boolean {
return this.elements.length === 0;
}
}
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // Output: 20
console.log(numberStack.peek()); // Output: 10
const stringStack = new Stack<string>();
stringStack.push('a');
stringStack.push('b');
console.log(stringStack.pop()); // Output: 'b'
console.log(stringStack.peek()); // Output: 'a'
In this example, the Stack
class uses a generic type parameter T
, allowing you to create stacks for different types of elements.
While the methods push
, pop
, peek
, and isEmpty
work with the type T
, ensuring type safety throughout the class.
Hopefully this quick intro to generics has helped answer any questions you might have had so far.
Generics make your code flexible, reusable, and type-safe, while also helping you avoid the mess of duplicating code for different data types. So whether you’re dealing with collections, building APIs, or designing data structures, generics ensure your code is robust and maintainable.
If you haven't tried generics yet, definitely give them a shot. They’ll save you time and headaches, and make your code cleaner and more efficient.
If you struggled to grasp some of the concepts around generics in TypeScript and how to use them in this guide, or just want a deeper dive into TypeScript, then check out my complete course on Typescript here.
You can even watch the first few lectures here for free.
The best part? When you join, you'll have direct access to me, other students, as well as full-time TypeScript devs in our private Discord channel - so you'll never be stuck!
If you've made it this far, you're clearly interested in TypeScript so definitely check out all of my TypeScript posts and content: