What is generics?

7 minutes read

This topic, like others, has subtleties; each will be thoroughly covered. However, the mechanisms discussed here are quite straightforward and can be understood easily. So, prepare yourself; by the end of this chapter, your questions about Generics will be replaced with the desire to make all your custom designs universal.

General concept

Imagine you have an exclusive telephone, but it can only dial one specific number. That's quite restrictive, isn't it? Now, envision Generics as a fantastic upgrade for your phone. Generics in TypeScript transform your single-number phone into a multi-contact device. Instead of being limited to one number, Generics let your phone call any number you desire. It becomes flexible and multifunctional, ready to connect with different people or, in terms of coding, various data types.

In simpler terms, let's say you have a type A that includes a field of type number. Then, if a type B is needed, the only difference from type A is that it belongs to a different type. You will need to declare it, or in line with our analogy, you will need another phone.

Generic programming is a method in which algorithms can process data from different data types in the same way, without needing to change the declaration (type description).

Basic Syntax of Generics

There exists a naming convention in Generics. Typically, this type is specified by a single capital letter, most frequently T. The basic syntax involves applying type parameters enclosed in angle brackets (<>). Now, let's take a look at the syntax of generics in class declarations:

class Box<T> {/* ... */}

Here, <T> functions as a placeholder for the actual type. It enables the Box class to work with any data type.

You can include as many type parameters as you need. You can specify numerous types, separated by commas:

class Box<T, U, X..> {/* ... */}

Working with Generic Types in Classes

class Box<T> {
  private data: T;
  constructor(value: T) {
    this.data = value;
  }
  getValue(): T {
    return this.data;
  }
}

const numberBox = new Box<number>(42);
const stringBox = new Box<string>("Hi, Generics!");

const numValue: number = numberBox.getValue();
const strValue: string = stringBox.getValue();

This shows how you can use a single generic class to create containers for both numbers and strings.

Where are generics used?

Generics in TypeScript help developers create flexible and reusable code for various programming structures. Let's dive deep into how we can utilize generics in different parts of our code and how this makes our code more advanced and user-friendly. They're typically used in the following structures:

  • Functions: The use of generics in functions paves the way for a unique function that can smoothly work with various data types.

  • Interfaces: Generics enhance the potency of interfaces by producing dynamic contracts that adapt to diverse data types. Implementing generics in interfaces allows you to disregard certain types, resulting in your code being less bound to specific implementations.

  • Type aliases: Generic type aliases let you establish a reusable name for a specific type that can adapt to varying data types. Much like other generic structures, it provides an extra level of flexibility by enabling the use of different types while preserving a consistent structure.

  • Classes: Generic classes allow you to design classes that can function with different types while assuring type safety. This way, you can avoid creating repetitive code for various data types, leading to cleaner and easier-to-maintain code.

In essence, generics act like a universal language converter letting these different programming structures communicate with a broad spectrum of data types, keeping the integrity of your code intact.

Using Type Parameters in Generic Constraints

With TypeScript generics, we use a construction. The extends keyword specifies our constraint. The type parameter <T extends X> declares that the generic type T must be a subtype of or equal to the specified type. Here's a deeper look at the parts:

  • <T>: This introduces a generic type parameter named T. Generic type parameters allow us to develop flexible and reusable components in TypeScript.

  • extends: The extends keyword is vital for generic type constraints. It narrows down the generic type T to a specific collection of types or a type hierarchy.

  • X: This stands for the constraint itself. It clarifies that the generic type T should be interchangeable with the X type. Basically, T can only be a string or a type that comes from or equals the X type.

For instance, <T extends string> suggests that when you are utilizing this generic type, you can substitute T with any type that is a subtype of or equal to the string type. This constraint confirms that operations or methods inside the generic class or function can safely presume the generic type T is compatible with string-specific operations.

Constraining an Object

Look at this example using the getLength function, where you print an object's length. The compiler gives an error since it can't determine if the arg has a length property:

function findLength<T>(arg: T): void {
  console.log(arg.length);     // Error: Property 'length' does not exist on type 'T'
}

You can extend the T with a length property {length:number}.

function getLength<T extends { length: number }>(arg: T): void {
  console.log(arg.length) // You can now use the 'length' property
}

getLength(10);                        // Error
getLength("Hello!");                  // Output: 6
getLength([11, 12]);                  // Output: 2
getLength({ length: 2, width: 10 });  // Output: 2

In this function, the arg parameter has type T, which represents the object from which you want to get the length property. The function has a single type parameter T, constrained by { length: number }. Here, T is the type of the argument you pass to the function, and the restriction { length: number } ensures that the type T must contain a length property of type number. This improves type safety and allows the function to work with different objects having a length property, like strings, arrays, and objects with a length property.

Interface as a Constraint for Generic Type Parameter

You can also extend from an interface. In this example, the interface ILength with the property length constrains T.

interface ILength {
  length: number;
}

function getLength<T extends ILength>(arg: T): void {
  console.log(arg.length) // Length property can now be called
}

getLength(10);                        // Error
getLength("Hello!");                  // Output: 6
getLength([11, 12]);                  // Output: 2
getLength({ length: 2, width: 10 });  // Output: 2

This interface signifies objects with a length property.

Type Parameter Constrained by Another Type Parameter

TypeScript permits the declaration of a type parameter constrained by another type parameter.

function getPropertyValue<T, K>(obj: T, key: K): void {
    console.log(obj[key])  // Error: Type 'K' cannot be used to index type 'T'
}

In this instance, bracket notation obj[key] is used to access the value of a property. TypeScript produces an error because it cannot certify that a property exists.

That's why we utilize the keyof type operator to create a new Type Parameter that extends the type T:

function getPropertyValue<T, K extends keyof T>(obj: T, key: K): void {
  console.log(obj[key])
}

const person = {
  id: "0",
  name: "John Smith",
  age: 19,
}
getPropertyValue( person, "name");     // Output: John Smith
getPropertyValue( person, "address");  // Error: Argument of type "address" is not assignable 
                                       // to parameter of type "name" | "id" | "age"

In the example, the type T is inferred as the type of the person object, and K is considered as the type of the string literal "name". The function then successfully retrieves and logs the value of the name property from the person object, but throws an error when trying to access the "address" property, which does not exist.

So, this function is a generic one ensuring type safety by requiring the key parameter to be a valid key of object type T. In this way, TypeScript assists in catching potential errors at compile-time if an attempt is made to access a non-existent property.

Real-world Examples of Generics

Generics excel in everyday scenarios, particularly when you need to create flexible data structures. For example, a generic collection can be used to develop a versatile array.

class ArrayOfAnything<T> {
  constructor(public collection: T[]) {};
  get(index: number): T {
    return this.collection[index];
  }
}

const arrayOfNumbers = new ArrayOfAnything<number>([7, 8, 9]);
const arrayOfStrings = new ArrayOfAnything<string>(['h', 'e', 'llo']);

console.log(arrayOfNumbers.get(0)); // Output: 7
console.log(arrayOfStrings.get(1)); // Output: e

This code introduces a generic class ArrayOfAnything that lets you make instances of arrays with varying data types. Here, rather than creating unique classes for every type, there's a single class for both string and number. The class features a constructor that starts an array (collection) of type T and a method get accepting an index to return the element at that index from the collection. After creating two instances of types number and string, you can access elements with the get method. This instance showcases the adaptability of the generic class, enabling you to make arrays of different types while preserving type safety.

Benefits of Using Generics

Generics let you create functions and classes compatible with various data types, ensuring type safety. This ability offers multiple substantial benefits; it makes your code more adaptable, understandable, and maintainable. Let's explore the benefits of using generics in your projects.

Code Reusability: Generics support the making of versatile components that you can reuse in multiple scenarios, thus decreasing redundancy.

Type Safety: With generics, TypeScript provides robust typing, identifying potential errors during compile time and increasing code reliability.

Adaptability: Generics help create components, which can effortlessly adapt to differing data types, fostering coding flexibility.

Conclusion

Generics aren't just one thing; they're a series of techniques united by one goal: dealing with generalized code in a way that caters to specific data types. You can consider generic classes as dynamic containers that adapt based on what you wish to store inside. They're akin to versatile boxes that any data type can comfortably inhabit, making your code more adaptable and enjoyable to use. Generics truly shine when you identify behaviors reproduced across various types. In conclusion, generics in TypeScript present a powerful means to develop flexible and reusable code; they allow you to define functions, classes, and interfaces without needing to spell out the precise types they will address.

How did you like the theory?
Report a typo