Mastering Generics in Typescript

Mastering Generics in Typescript

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.

  1. The first line of the function contains two generic types <T, K> where T is an object and has a constraint that it must have an object property of {id: number } and K is a key property of the object T (using the keyof operator) and has a constraint that it must only contain properties from T object (using the extend keyword).

  2. The second line contains the parameters the function receives and what the function returns.

    • arr: is an array of our object T.

    • key: is a key property of object T.

    • id: is a number unique to every object T in the array.

    • The function getObjectValue then returns the value of the object key property found, T[K], or undefined if the value is not found.

  3. The third line checks through the array using the id as a criterion, finding the object with the same id.

  4. 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 key foundObject[key] and return it, or return undefined 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.

References

I'd love to connect with you on Twitter | LinkedIn | GitHub

Did you find this article valuable?

Support Jamin's notes by becoming a sponsor. Any amount is appreciated!