Let's talk about Typescript Generics

Profile picture of Bhavik Bamania
Bhavik Bamania
·5 min read
Mastering TypeScript Generics: Enhancing Code Reusability and Type Safety
Mastering TypeScript Generics: Enhancing Code Reusability and Type Safety

In this article, we explore TypeScript Generics, a powerful feature inspired by C# that enhances type safety and code reusability. We will delve into the fundamentals of generics, learning how to create generic functions and classes, apply constraints, and leverage their benefits in TypeScript development. Whether you’re a beginner or an experienced developer, this guide will equip you with the knowledge to effectively utilize generics in your projects. Discover how TypeScript generics can improve your code quality and efficiency today!

Before you proceed

If you are going with the sequence, then you must be aware of concepts like what is typescript, how we can configure typescript from scratch, what are types in typescript, and how you can work with them while working with Javascript classes and objects.

However, if you are not aware of any of these and came across this article directly, don’t worry here you can take a deep dive into the above-mentioned topics.

What are Generics?

Generics in TypeScript allow you to create components, classes, or functions that work with a variety of data types while maintaining strong type safety. Think of generics as a way to connect one type with another type, offering flexibility without sacrificing the benefits of type checking.

const someVar: Type<T>

Here, T is a generic type that can be replaced with any specific type, making someVar adaptable while ensuring that it still conforms to type rules.

Why Use Generics?

Generics are useful for creating reusable code components that can work with different data types. They enhance type safety in complex functions or classes, especially when dealing with arrays, promises, or other intricate data structures.

Let’s explore some common uses of generics:

Array Type

const names: Array<string>= ['Pyramid', 'Oscar']; // similar to string[]

Promise Type

const promise: Promise<string> = new Promise((resolve, reject)) => {
  setTimeout(() => {
    resolve('done');
  }, 2000);
});

In these examples, generics ensure that the types within arrays and promises are consistent, improving code reliability and readability.

Creating a Generic Function

Let’s say you want to merge two objects into one. Here’s a simple function that does that:

function merge(obj1: object, obj2: object) {
  return Object.assign(obj1, obj2);
}

const mergeObj = merge({ name: "Pyramid" }, { surname: "Oscar" });
console.log(mergeObj.name); // Error: Property 'name' does not exist on type 'object'

In the above code, even though mergeObj should have a name property, TypeScript doesn't know that. We can use typecasting to fix it:

const mergeObj = merge({ name: "Pyramid" }, { surname: "Oscar" }) as { name: string, surname: string };
console.log(mergeObj.name); // Now it works, but it's cumbersome

Instead of manually casting the type, let’s use generics:

function merge<T, U>(obj1: T, obj2: U): T & U {
  return Object.assign(obj1, obj2);
}

const mergeObj = merge({ name: "Pyramid" }, { surname: "Oscar" });
console.log(mergeObj.name); // Works beautifully!

How Does It Work?

By declaring <T, U>, we create a generic function that accepts two types. When calling merge, TypeScript infers these types based on the provided arguments. The return type T & U indicates that the result will be an intersection of both types.

function merge<T, U>(obj1: T, obj2: U) { // returns T & U
  return object.assign(obj1, obj2);
}

const mergeObj = merge({name: "Pyramid"}, {surname: "Oscar", hobby: ["chess"]});
console.log(mergeObj.name) // it works!
merge({name: "James"}, {surname: "Foster"}) 
console.log(mergeObj.name) // it works!
// in both scenario it will infer different values for T & U

We can specify these types in place of T & U. However, we should avoid that since typescript dynamically assigns the relevant types to the Generics.

function merge<T, U>(obj1: T, obj2: U) { // returns T & U
  return object.assign(obj1, obj2);
}

const mergeObj = merge<{name: string, hobbies: string[]}, {surname: string}>({name: "Pyramid"}, {surname: "Oscar", hobby: ["chess"]});
console.log(mergeObj.name) // it works!
merge({name: "James"}, {surname: "Foster"}) 
console.log(mergeObj.name) // it works!
// in both scenario it will infer different values for T & U

Working with Constraints

Generics become even more powerful with constraints. Constraints allow you to specify that a generic type must satisfy certain conditions.

Suppose you want to merge only objects. Here’s how you can enforce that:

function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return Object.assign(obj1, obj2);
}

const mergeObj = merge({ name: "Pyramid" }, { surname: "Oscar" });
console.log(mergeObj.name); // Works fine!

By adding extends object, we ensure that both T and U must be objects. This constraint helps prevent errors and makes your code more robust.

The “keyof” Constraint

Another useful constraint is keyof, which ensures that a type must be a key of another object.

function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
  return obj[key];
}

console.log(extractAndConvert({ name: "Oscar" }, 'name')); // Correct!

Here, U must be a key of T, which ensures that the key passed to extractAndConvert is valid.

Generic Classes

Generics can also be applied to classes, allowing you to create flexible and type-safe data structures.

class DataStorage<T> {
  private data: T[] = [];

  addItem(item: T) {
    this.data.push(item);
  }

  removeItem(item: T) {
    const index = this.data.indexOf(item);
    if (index !== -1) {
      this.data.splice(index, 1);
    }
  }

  getItems(): T[] {
    return [...this.data];
  }
}

const textStorage = new DataStorage<string>();
textStorage.addItem("Pyramid");
textStorage.addItem("Oscar");
textStorage.removeItem("Pyramid");
console.log(textStorage.getItems()); // ['Oscar']

In this example, DataStorage is a generic class that can store any type of data, ensuring that only items of the specified type can be added or removed.

Handling Non-Primitive Data Types

To prevent issues with non-primitive data types, you can add constraints to the generic class:

class DataStorage<T extends string | number | boolean> {
 // Class implementation remains the same
}

This constraint ensures that DataStorage works only with primitive data types, avoiding potential pitfalls with objects or arrays.

Difference between Generic Types vs Union Types

Generics and union types both allow flexibility but serve different purposes. Generics create a relationship between types, making components adaptable, while union types provide a fixed set of possible types.

Summary

Generics in TypeScript provide a powerful way to write flexible, reusable, and type-safe code. They allow you to:

  • Create adaptable components that work with various data types.
  • Enhance type safety without sacrificing flexibility.
  • Use constraints to enforce type rules and avoid errors.

Conclusion

By understanding and using TypeScript generics, you can build more robust and maintainable applications. With practice, you’ll find them indispensable for handling complex data structures and enhancing type safety in your code. Happy coding!

Author of Bhavik Bamania

Written by

Bhavik Bamania