TypeScript Learning Adventures: A Tale of Love and Hate - Generics

While TypeScript supports generics, vanilla JavaScript does not. Yet, generic types exist in other programming languages such as C++, C#, and Java. In this article, we will look into generics and their meaning.

TypeScript Learning Adventures: A Tale of Love and Hate - Generics
Photo by Aquaryus15 / Unsplash

While TypeScript supports generics, vanilla JavaScript does not. Yet, generic types exist in other programming languages such as C++, C#, and Java.

In this article, we will look into generics and their meaning. I will explain how to create generic functions and classes. And after that, we will see some practical implementations for generic constraints.

Also, we will look at certain TypeScript utility types that are generic in nature. These utility types will help us comprehend this important language concept even more.

What are generics?

To better understand generics let's look at an essential type that comes pre-defined in TypeScript and one we often work with.

The arrays.

Arrays in TypeScript have a general nature by default, making them a great place to start when learning the idea of generics.

Let's say we have an array of names:

const names = ["Geralt", "Yenn"];

As a result, there are two names in the code, and their type is an array of strings. You may view the following by moving your mouse over the "names" variable:

const names: string[]

Actually, we could conceive of this as a combination of two types, an array, and a string. Same as you can have an object with many properties, each of which can store a distinct type.

An array is always an entity that contains diverse data, in this case, strings. So we have the array type, and if I remove the values from this names array, TypeScript will infer that this is an array of any type with any data in it:

const names = [];

and if you now hover over it, you will see:

const names: any[];

TypeScript is also aware of the Array type. So we might claim that this should be an Array, however as you can see if I described it like this:

const names: Array = [];

// shows an error
// Generic type 'Array<T>' requires 1 type argument(s).

we encounter an error, despite the fact that the Array type exists in TypeScript. As we can see, it is a generic type that accepts only one type of argument and can not be without arguments.

This strange phrasing in error there, specifying T type, suggests that you're working with a generic type. So anytime you see something like this in TypeScript you should know that this is a generic type.

A generic type is one that is related to another type but has a wide range of possibilities for what that other type is.

Does that sound challenging? Let's return to the array example.

Citrus fruit flat order
Photo by Andre Taissin / Unsplash

So an array itself might be a type, it's a list of data that, on its own, makes up a type. As an object is a type in and of itself, even if we don't know what type of data object properties hold.

An array is a collection of data of a given type. The array type is not concerned with the type of data it stores. The array doesn't care whether it's storing a list of strings, numbers, objects, or mixed data.

While the array type is not concerned with the exact contents, it does need certain metadata. Even if we convey our uncertainty by saying "I don't know," it requires the most basic facts, such as:

const names: any[] = [];

Using any is preferable over not specifying anything.

This is one way to define an array type, the type of data contained within it, and then square brackets. Another way to achieve the same outcome is to use the Array type and then these angle brackets:

const names: Array<string>= [];

Between these angle brackets, you can describe the type of data saved in the array. For example, consider a string, which is the same as defining this type here:

const names: string[] = [];

So this is a concept of a generic type, constructed with TypeScript. A type that is related to another type, and we want to know which one it is so that TypeScript can better support it.

Because we know that anything we store in there will be a string, we know that if I read an element from that array, I can call string functions with it:

const names: Array<string> = ["Geralt", "Yenn"];

const firstNameSplitted = names[0].split('e');

Now I can call all these string methods, such as the .split() method. TypeScript will not complain since it recognizes that this array contains strings.

That is the aim of generic types. They provide greater type safety.

How to create generic functions?

Assume you need to write a function to drop duplicate numbers from an array. It's a simple problem to solve:

function deduplicateArray(arr: number[]): number[] {
  return Array.from(new Set(arr));
}

const result = deduplicateArray([1, 2, 3, 2]);
console.log(result); // [1, 2, 3]

Everything was well until you got a request to implement the same for string arrays. You are now confronted with a predicament.

Just a few film cameras,
shot on film. Kodak Gold 200
Photo by weston m / Unsplash

Your function only accepts numeric arrays. You could create another function, and then call it when you have an array of strings. But, this is far from the perfect solution:

  • you repeat the code, violating the DRY principle,
  • the implementation is the same as for an array of numbers function.
  • sometimes, you don't know if the variable will be a string or a number array. You need to check the type before calling the correct function.

There are many issues, but generics provide an elegant solution. Let's use generics to implement the above function:

function deduplicateArray<T>(arr: T[]): T[] {
  return Array.from(new Set(arr));
}

console.log(deduplicateArray(["1", "2", "3", "2"])); // ["1", "2", "3"]
console.log(deduplicateArray([1, 2, 3, 2])); // [1, 2, 3]

By putting these angled brackets after the function name, we make it a generic function. Then we create identifiers.

You begin with T, which is shorthand for "type" but you can use any identifier here. It doesn't have to be a single character, but the tradition is to use a single character.

So whatever array we get here is of type T, and this T represents any type, string, number, boolean, and so on. You can add as many identifiers as you need, but they must have different names.

In the end, this is what generics are all about. The ability to fill in different concrete types for different function calls.

The types of values we're sending as arguments are inferred by Typescript. The function call is placed in the inferred types for the T type. In the end, that's how generics work behind the scenes.

Constraints in generics

Now, in our deduplicateArray function, we would have a problem if we pass in an array of objects here:

function deduplicateArray<T>(arr: T[]): T[] {
  return Array.from(new Set(arr));
}

console.log(
  deduplicateArray([
    { name: "Geralt" },
    { name: "Yenn" },
    { name: "Tris" },
    { name: "Dandelion" },
  ])
); 

// output:
// [
//    { name: "Geralt" },
//    { name: "Yenn" },
//    { name: "Tris" },
//    { name: "Dandelion" },
// ]

When I run this code, it compiles without issues, and when I print the array, I can see that it fails silently.

This function does not operate on objects because they are not primitive types. We meant to use it exclusively on strings or numbers.

And we're not saying that right now. We're saying right now that the array can be of any type.

Geralt of Rivia Nendoroid
Photo by Daniel Lee / Unsplash

That's not always acceptable, so if you want to limit the types of T here, you should put some constraints on your generic types:

function deduplicateArray<T extends string | number>(arr: T[]): T[] {
  return Array.from(new Set(arr));
}

console.log(
  deduplicateArray([
    { name: "Geralt" },
    { name: "Yenn" },
    { name: "Tris" },
    { name: "Dandelion" },
  ])
); 
// throws an error: Type '{ name: string; }' is not assignable to type 'string | number'

console.log(deduplicateArray([1, 2, 3, 2])); 
// works OK, prints [1, 2, 3]

You can place limitations on the types that your generics by using the extends keyword. This keyword should be in the angled brackets following the type that you want to constrain.

So you say here that it extends string or number. With that, you are stating that the T type can be any type as long as it extends string or number.

You have a lot of flexibility here, and you can create any constraints you want. Or you don't even have to confine your generic types at all.

Yet, it is critical to understand the concept of constraints. It allows you to work with generic types in a more efficient manner. It also helps you avoid superfluous mistakes or weird behaviors.

How to use the "keyof" constraint

You can use the keyword keyof when working with objects in generic functions.

For this, we can write another generic function called pickProperties. The idea behind this function is that we receive 2 arguments:

  • an object as the first argument
  • and an array of strings as the second

The function returns that object but it only contains keys from the second parameter:

function pickProperties<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>{
  const result = {} as Pick<T, K>;
  keys.forEach((key) => (result[key] = obj[key]));
  return result;
}

const person = {
  name: "Geralt",
  age: 104,
  gender: "male",
  address: "Kaer Morhen, Kaedwen",
};

const picked = pickProperties(person, ["name", "age", "address"]); 
// { name: "Geralt", age: 104, address: "Kaer Morhen, Kaedwen" }

const invalidKeys = pickProperties(person, ["name", "telephone"]);
// throws an error: Type '"telephone"' is not assignable to type '"name" | "age" | "address" | "gender"'

In comparison to previous examples, now we have 2 generic arguments:

  • T which stands for any object
  • K stands for the second generic argument but is constrained by the keyof keyword for the T argument. With this, we are saying, this K argument symbolizes the property inside the T object. Any property.

We create a new empty object within the function. With the Pick utility type, we tell TypeScript that this new object should have any property K from our object T. Then, with a forEach loop, we iterate through object T and fill the object with desired properties from the keys array.

As you can see, the result object consists only of targeted attributes. We also get an error if we try to pass a property that does not exist in the object.

So these are some examples of how we can utilize generic types with the keyof keyword.  With it, we can inform TypeScript that we want to ensure that we have the correct structure. That is quite beneficial because it prevents us from making foolish mistakes.

Can I create generic classes?

Yes, you can!

Let's examine how we'd go about creating such a generic class and why it could be valuable to us.

Portrait of a Medieval warrior
Photo by Gioele Fazzeri / Unsplash

Let's imagine we need to build a class representing Inventory in the video game:

class Inventory {
  private items = [];

  addItem(item) {
    this.items.push(item);
  }

  removeItem(item) {
    this.items.splice(this.items.indexOf(item), 1);
  }

  getItems() {
    return [...this.items];
  }
}

Now we're getting issues because we're not specifying what sort of data we're saving. Another error is because of the type of item we are pushing or removing from an array.

And this is where we can transform this into a generic class because you may not care about the data type. We want to ensure that the data is uniform, thus it must be either strings, numbers, or objects.

So let's turn this into a generic class:

class Inventory<T> {
  private items: T[] = [];

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

  removeItem(item: T) {
    this.items.splice(this.items.indexOf(item), 1);
  }

  getItems() {
    return [...this.items];
  }
}

So we made this a generic class by putting angle brackets after the class name. Then we add T or whatever you want to use as an identifier.

After that, we are adding a generic type for the items array and the item parameter in class functions.  This tells TypeScript that this is an array of type T, and it will store data of that generic type in it. Also, in the addItem and removeItem methods, we add and attempt to remove such data.

Now we can use this for different purposes:

const playerParty = new Inventory<string>();

playerParty.addItem("Geralt");
playerParty.addItem("Yenn");
playerParty.addItem("Tris");
playerParty.removeItem("Tris");

console.log(playerParty.getItems());
// ["Geralt", "Yenn"]

Why would we create such a generic class?

As I before stated, we may not only want to keep strings. We may also want to save some numbers in a separate inventory:

const studentMathGrades = new Inventory<number>();

studentMathGrades.addItem(4);
studentMathGrades.addItem(1);
studentMathGrades.addItem(3);
studentMathGrades.removeItem(1);

console.log(studentMathGrades.getItems());
// [4, 3]

Of course, we could use a union type to create an inventory that accepts both strings and numbers. So we have full flexibility there.

Still, we'll have one issue with our Inventory class. Assume I have my object storage here:

const gameCharacters = new Inventory<object>();

gameCharacters.addItem({ name: "Geralt", age: 104 });
gameCharacters.addItem({ name: "Tris", age: 80 });
gameCharacters.addItem({ name: "Yenn", age: 94 });
gameCharacters.removeItem({ name: "Tris", age: 80 });

console.log(gameCharacters.getItems());
// [{ age: 104, name: "Geralt" }, { age: 80, name: "Tris" }]

The problem here is that we are dealing with objects, which are reference types in JavaScript.

Our class logic for removing and identifying data works fine for primitive types. Still, we are not doing a good job when working with non-primitive values.

So, whether we work with objects or strings, indexOf will not work if we pass in an object here. This is because this is a new object that we pass into the removeItem function. It may resemble the one in the array, but it does not work. This is because this is a completely new object in memory with a different address.

And, yes, JavaScript will check for the address but will not find it. When an item is not found, the .indexOf function returns -1. So if you try to access the array item at index -1, you will get the last item. This is standard Javascript behavior. Because of this behavior it always eliminates the last element of the array.

So, to verify that the Inventory class works with our targeted types, we can only target those types:

class Inventory<T extends string | number | boolean> {
  private items: T[] = [];

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

  removeItem(item: T) {
    if (this.items.indexOf(item) === -1) {
      return;
    }
    this.items.splice(this.items.indexOf(item), 1);
  }

  getItems() {
    return [...this.items];
  }
}

We can also add a check for .indexOf so that we only use an element if it exists in the array.

Remember, you can have more than one generic type in the class. Not just the T, so when working with classes, you are not confined to a single type. You can also have methods with their own generic types rather than the ones used by the class. Constraints can be used anywhere.

In general, generic types are there to make your life easier and to provide full flexibility.

Generic utility types

Typescript comes with many generic utility types. So as a small bonus, these particular types may come in helpful from time to time.

Workbench and tools
Photo by Jeff Sheldon / Unsplash

Now, these utility types only exist in the world of types (not in JavaScript). As these utility types only exist in the TypeScript realm they are not compiled to anything. Yet, during compilation, they apply extra strictness, perform extra tests, or omit some checks.

One of these utility types is the Readonly type. It is quite handy when you need to "lock" something, such as an array or an object:

const names: Readonly<string[]> = ["Geralt", "Yenn"];

names.push("Triss");
// Property 'push' does not exist on type 'readonly string[]'

In the examples above we already used Pick type and in one of previous articles I wrote about TypeScript utility types like:

  • Partial
  • Required
  • Omit
  • Pick
  • Record,
  • and others

All these utility types are similar because they take any other value and perform something with it.

Why do we use generic types when we have union types?

To conclude this article, one typical cause of misunderstanding which I'd want to clarify here, is the distinction between generics and union types.

How could you confuse these two?

Take our previous example, Inventory class:

class Inventory<T extends string | number | boolean> {
  private items: T[] = [];

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

  removeItem(item: T) {
    if (this.items.indexOf(item) === -1) {
      return;
    }
    this.items.splice(this.items.indexOf(item), 1);
  }

  getItems() {
    return [...this.items];
  }
}

If we allow generic kinds depending on these types, we could change generics to union types. We want to store strings, numbers, or booleans in here, right?

So it should look like this:

class Inventory {
  private items: (string | number | boolean)[] = [];

  addItem(item: string | number | boolean) {
    this.items.push(item);
  }

  removeItem(item: string | number | boolean) {
    if (this.items.indexOf(item) === -1) {
      return;
    }
    this.items.splice(this.items.indexOf(item), 1);
  }

  getItems() {
    return [...this.items];
  }
}

What's the issue with this approach?

At first glance, it appears to do the same thing. But it actually accomplishes something quite different.

What we mean is that we can store any type of data in there as long as it's an array of characters, numbers, or booleans. We may then put any type of data here, such as a string, number, or boolean, and remove it as well.

The issue here is that we're not saying whether this is an array of strings, an array of integers, or an array of booleans. This indicates that we have an array that can include strings, numbers, and booleans mixed together.

To fix this, we must state either an array of strings or an array of numbers, etc, as shown below:

private items: string[] | number[] | boolean[] = [];

but the problem now is that we get errors in the addItem and removeItem methods:

addItem(item: string | number | boolean) {
  this.items.push(item);
}

Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'.
  Type 'string' is not assignable to type 'never'.

We're adding a string, a number, or a boolean. But depending on whether we set this data array to be a number array, we're not permitted to add a boolean or string. If we make this a string array, we won't be able to add a number in the addItem method. So that's the issue with union types.

We got a different configuration when we use generic types.

Here, we say you must first decide what type of data you wish to save, and then you may only add that type of data.

If you want to lock in a specific type, generic types are ideal. Use the same type throughout your whole class instance.

Generic types always lock a type.

You want to use union types when you can have a different type with each method call.

Conclusion

In conclusion, TypeScript generics provide a powerful mechanism for writing reusable and flexible code.

Generics allow us to create functions, classes, and utility types that can operate on different data types, enhancing code abstraction and reducing duplication.

Understanding and utilizing TypeScript generics opens the door to writing more modular, scalable, and type-safe code.