Generics are one of the superpowers of TypeScript, and while they might look scary if one has little or no knowledge about them, they can be life-saving when understood and used correctly. Mastering generics in TypeScript can significantly enhance one's ability to write clean, robust, and scalable code. In this article, we will explore the fundamentals of generics, delve into some of its concepts, and provide practical examples to help individuals become better users of generics in TypeScript.
What are Generics in Typescript?
Generics enable us to create reusable components that work with different data types while maintaining type safety. They provide a way to define placeholders for types that are determined when the code is used. This enables us to write more generic and flexible code.
Consider this example of this function below:
function echo(value) {
return value;
}
//Call the function
echo("Hello World")
The echo
function receives a value and returns that value without any modification. At the moment, TypeScript frowns at our function parameter and complains that “parameter 'value' implicitly has an 'any' type.“ meaning that since there’s no type explicitly specified, TypeScript has inferred a type of any
to the parameter of the function. This doesn’t help us check the data of the parameter of our function, so how do we check that the right type is passed when our function is called and that our function also returns the correct data?
One way we could do this is by explicitly setting the type of parameter value
passed and returned.
function echo(value:string):string {
return value;
}
echo("Hello World")
This example works perfectly as we know the type of data being passed as a parameter and the data our function returns, but there is a tiny bit of an issue. What if we want to make the echo
function reusable and call it somewhere else with a different data type? Typescript will complain that we’re passing the wrong data type to the echo
function.
function echo(value:string): string {
return value;
}
//Argument of type 'number' is not assignable to parameter of type 'string'
echo(3.14159)
So how do we fix this? How do we create a reusable function that accepts and returns the types we pass to it? Generics!
Basic Generics
Starting with the basics, using the echo
function as an example, let’s spice it up and update the function with generics to be reusable.
function echo<T> (value: T): T {
return value;
}
//calls the function with no errors from typescript
echo("Hello World")
echo(3.14159);
echo(false);
echo([1, 2, 3, 4])
echo({id: 1, fullName: "John Doe"})
In this example, The echo function is a generic function that takes an argument value
of type T
and returns the exact value of type T
without any modification. The generic type parameter <T>
we passed to the function allows it to work with different types, thus allowing us to reuse the same function with different data.
Generally, you can write a generic type with any alphabet you like, <X>,<Y>
, but T
is for type
, and it's just a tradition of naming generics, and there is nothing to prevent you from using other names or alphabets.
Generics on Type Declarations
Generics don’t only work with functions
; we can also use generics to make types more robust, reusable, and flexible when declared. For example, let's say we are building an e-commerce application and have a Repository interface defining common CRUD operations for working with different entities in our system. Instead of creating separate interfaces for each entity (e.g., UserRepository
, ProductRepository
, etc.), we can use generics to create a single, generic Repository
interface that can handle various entity types.
interface Repository<T> {
getById(id: string): T | undefined;
getAll(): T[];
create(item: T): void;
update(item: T): void;
delete(id: string): void;
}
class UserRepository implements Repository<User> {
// Implementation specific to User entity
}
class ProductRepository implements Repository<Product> {
// Implementation specific to Products operation
}
// Usage
const userRepository: Repository<User> = new UserRepository();
const user = userRepository.getById('123');
userRepository.create(newUser);
const productRepository: Repository<Product> = new ProductRepository();
const products = productRepository.getAll();
productRepository.update(updatedProduct);
In this example, the Repository
interface is defined with a generic type parameter T
representing the entity type. It provides common methods like getById
, getAll
, create
, update
, and delete
that can be used with any entity.
By implementing the Repository
interface with specific entity types like User
and Product
, we can create specialized repositories that handle operations particular to those entities. The generic nature of the interface allows for code reuse and flexibility when working with different types of entities.
This approach makes the code more modular, maintainable, and extensible, as we can easily add new entity-specific repositories without duplicating the same set of methods for each entity type.
Generics in Functions with Type Constraints
As we’ve seen from our first generics example in this article, we can use generics with functions and combine them with some other features of TypeScript to produce more exciting results. Let’s say we want a function called printLength
that accepts only items with lengths and prints the length of any items we pass on it. Using the extend
keyword in Typescript, we can create a function that constrains the data passed to the function.
interface Lengthy {
length: number;
}
function printLength<T extends Lengthy>(item: T): void {
console.log(`Length: ${item.length}`);
}
const stringItem = "Hello, World!";
printLength(stringItem); // Output: Length: 13
const arrayItem = [1, 2, 3, 4, 5];
printLength(arrayItem); // Output: Length: 5
const numberItem = 42; // Error: Type 'number' does not have a property 'length'
printLength(numberItem);
In this example, The printLength
function takes a generic type parameter T
that extends the Lengthy
interface using the extends
keyword. This means the type T
must have the length
property of the type number
.
Within the printLength
function, we can safely access the length
property of the item
argument without causing type errors because the generic type T
is guaranteed to have a length
property.
Using the printLength
function with items(stringItem
and arrayItem
) that satisfy the Lengthy
requirements, we got no errors from TypeScript. However, when we try to pass a number (numberItem
), which doesn't have a length
property, TypeScript raises an error.
Let’s consider another example that combines generics with some more inbuilt features of TypeScript. The function below accepts an array of objects, finds an object by the id
key property of the object, and returns the key
value we specified as a parameter when calling the function.
interface Person {
id: number;
name: string;
age: number;
}
function getObjectValue<T extends { id: number }, K extends keyof T>
(arr: T[], key: K, id: number): T[K] | undefined {
const foundObject = arr.find((obj) => obj.id === id);
return foundObject ? foundObject[key] : undefined;
}
const people: Person[] = [
{ id: 1, name: "John", age: 25 },
{ id: 2, name: "Jane", age: 30 },
{ id: 3, name: "Bob", age: 40 },
];
const nameValue = getObjectValue(people, "name", 2);
console.log(nameValue); // Output: Jane
Whoops! That looks like a complex function with lots of types flying everywhere. Let’s break it down bit by bit.
The first line of the function contains two generic types
<T, K>
whereT
is an object and has a constraint that it must have an object property of{id: number }
andK
is a key property of the objectT
(using thekeyof
operator) and has a constraint that it must only contain properties fromT
object (using theextend
keyword).The second line contains the parameters the function receives and what the function returns.
arr
: is an array of our objectT
.key
: is a key property of objectT
.id
: is a number unique to every objectT
in the array.The function
getObjectValue
then returns the value of the object key property found,T[K],
orundefined
if the value is not found.
The third line checks through the array using the
id
as a criterion, finding the object with the sameid.
The fourth line checks if the object
foundObject
we just searched for actually exists and if it does, we extract a value from it using its keyfoundObject[key]
and return it, or returnundefined
if it’s not found.
That’s it. We just broke down the getObjectValue
function into small chunks to better understand the generic type definitions it has.
Generics can have default values.
We can also use generics with default values when working with functions. On our previous getObjectValue
function, imagine we’d like to add a default parameter for our id
parameter as the default value our function filters by. We could rewrite the function like this:
function getObjectValue<T extends { id: number }, K extends keyof T>
(arr: T[], key:K, id:number = 2): T[K] | undefined {
const foundObject = arr.find((obj) => obj.id === id);
return foundObject ? foundObject[key] : undefined;
}
By updating the id: number
to be id: number = 2,
we set the default value to be 2
whenever our function runs.
Conclusion
In conclusion, we covered generics in TypeScript, how they make our code reusable, and how they help to add flexibility to our code using some examples of type declarations and functions.
Generics is one of the features of TypeScript that might look overwhelming at first, but once you get to know it better, you’ll appreciate how it makes your code more strictly typed.
I hope this article helps you better understand generics.
Thanks for reading. Cheers.