Master TypeScript Generics

Master TypeScript Generics

·

5 min read

Generics is a concept in programming that allows us to create reusable code. I will first talk about what generics are and then I will show some simple and advanced examples to enforce what we've learned.

If you wish to see a real life example of generics in a React application, make sure to check out my video below:

What are Generics

Creating Resuable Code in JavaScript

In JavaScript you can write functions and classes and pass arguments to them to customize the functionality of these functions and classes. For example, instead of writing the function below:

function readTextFile(textFileName) {
    textFileReader.read(texFileName)
}

readTextFile('file.txt'); // will work fine
readTextFile('file.csv'); // error
readTextFile('file.docx'); // error

We can write a more generic function that can work with any file instead of working with only text files:

function readAnyFile(anyFileName) {
    anyFileReader.read(anyFileName)
}

readAnyFile('file.txt'); // will work fine
readAnyFile('file.csv'); // will work fine
readAnyFile('file.docx'); // will work fine

Creating Resuable Code in TypeScript

While there are many ways to create reusable code in TypeScript, I will focus only on Generics in this post.

In TypeScript, not only we can pass arguments to functions or class instances, but also we can pass different types as generics to customize the functions and the classes. So instead of writing an implementation of an array of numbers and array of string for example, we can write a more general ArrayOfAnything class and customize it with generics:

Generics in Classes

class ArrayOfNumbers() {
    constructor(private collection: number[]) {}

    get(index: number): string {
        return this.collection[index]
    }
}

class ArrayOfStrings() {
    constructor(private collection: string[]) {}

    get(index: string): string {
        return this.collection[index]
    }
}

const arrayOfStrings = new ArrayOfStrings(['1', '2']);
const arrayOfNumbers = new ArrayOfNumbers([1, 2]);

we can write one general class instead, and pass a generic type called T to the class that can be changed based on we instantiate the class. The syntax is as shown in the example:

class ArrayOfAnything<T> {
    constructor(private collection: T[]) {}

    get(index: number): T {
        return this.collection[index]
    }
}

const arrayOfStrings = new ArrayOfAnything<string>(['1', '2']);
const arrayOfNumbers = new ArrayOfAnything<number>([1, 2]);
const arrayOfBooleans= new ArrayOfAnything<boolean>([true, false]);

Generics in Functions

Let's see examples of generics with functions. Instead of writing repetitive code like this:

function printStrings(arr: string[]) {
    arr.forEach(str => {
        console.log(str)
    });
}

function printNumbers(arr: number[]) {
    arr.forEach(num => {
        console.log(num)
    });
}

we can simply write:

function printElements<T>(arr: T[]): void {
    arr.forEach(element => {
        console.log(element)
    });
}

printElements(['1', '2', '3']); // element is inferred by TS as 'string'
printElements([1, 2, 3]); // element is inferred by TS as 'number'

The Generic Type T is not a special letter. We can pass anything, but the letter T is coomnoly used in codebases.

Generics vs. 'any'

At this point you might ask yourself, 'why don't we simply use the keyword any?' The answer is for two reasons:

  1. We need to avoid the any keyword in TypeScript because that will disarm us from leveraging the power of TypeScript and our code will become JavaScript not TypeScript.

  2. Although generics can be subsituted by any type passed to the function or class, they are more specific than any. The diagram below shows the difference:

    TypeScript cannot differntiate between the different types of variables and arguments when they have the type any. But TypeScript can successfully infer the types of variables and arguments when they are typed with a generic instead of any.

function printElements(arr: any[]): void {
    arr.forEach(element => {
        console.log(element)
    });
}

printElements(['1', '2', '3']); // element is inferred by TS as 'any'
printElements([1, 2, 3]); // element is inferred by TS as 'any'

Generics Constraints

Let's assume we have the code below:

You can see that TypeScript is complaining with this error:

That's very expected because I simply can pass any array, like an array of numbers, and TypeScript here is playing it safe and assuming the worst case scenario. So we need to tell TypeScript that T is not any generic, but a specific type of generic. To do that we define an interface Printable to tell TypeScript that the generic T extends the properties of that interface as follows

Let's see a more advanced syntax on generics constraints:

In this code snippet, I can pass any string to the user.getKey() method and TypeScript will have no problem in detecting that we are trying to access a key that doesn't exist. Using generics, we can refine our code and take full advantage of TypeScript's type checking system, and tell TypeScript that the argument we are passing to the getKey() function is not only a string, but a specific type of string:

and now TypeScript is complaining for good:

Let's break down the syntax.

In JavaScript and TypeScript, all object keys are strings behind the scenes. Even if you can set a number as an object key, behind the scene JavaScript transforms it into a string. So for this reason, TypeScript understands that keyof T is a specific string, not just any string, and that's why we are getting these errors because TypeScript now expects us to pass only strings that are keys of the initial object we created when instantiating the class.

In the examples above I demonstrated generics in functions and classes, but they can be used with interfaces and methods in similar ways.

You've learned about generics, and how they are used as type parameters for classes and functions, and how they are used to make more specific types of general types like any or string.