Photo by Oskar Yildiz on Unsplash

Uncovering one of the most powerful tools in typed languages.

As programmers, one of the things we are taught to do and that is drilled into our heads is to not write duplicate code. There is a whole software development principle for it called DRY, or Don’t Repeat Yourself. This principle makes it easier and more reliable to make changes to your application, since you should only have to change one spot to update the functionality and you don’t have to worry about unexpected behavior caused by some other place in the code where the functionality is duplicated.

There are many abstractions we can use to DRY up our code, like using interfaces, classes, and functions, but statically typed languages, like TypeScript, have another trick up their sleeve, generics.

Basics

In the world of typed languages we might encounter some functionality that we want to implement for multiple types. For example, let's say we want to concatenate two lists together without using the built-in Array.concat. We want to support lists of strings and lists of numbers. In TypeScript we might define the following:

function concatNumbers(first: number[], second: number[]): number[] {
    const arr: number[] = []
    first.forEach(item => arr.push(item))
    second.forEach(item => arr.push(item))
    return arr
}

function concatStrings(first: string[], second: string[]): string[] {
    const arr: string[] = []
    first.forEach(item => arr.push(item))
    second.forEach(item => arr.push(item))
    return arr
}

This works for lists of strings and lists of numbers, but it is not great. If we want this functionality to change we will have to change both of the functions, which is not ideal. Also if we want the this functionality for lists of some other type we will have to define a new one.  This goes against the DRY principle we discussed earlier, and we can do much better with generics like so:

function concat<T>(first: T[], second: T[]): T[] {
    const arr: T[] = []
    first.forEach(item => arr.push(item))
    second.forEach(item => arr.push(item))
    return arr
}

The first thing to note is the T type parameter. When dealing with generics we define them in angle brackets and give them a capitalized letter to represent any and all types. Since T can represent any and all types, all of the following usages will work:

concat<string>([“a”, “b”], [“c”, “d”])  // => [“a”, “b”, “c”, “d”]

concat<number[]>([[1], [1, 2]], [[3], [4]])  // => [[1], [1,  2], [3], [4]]

concat([1, 2], [3, 4])  // => [1, 2, 3, 4]

Note that in the last usage we did not pass in a type to the concat function and it still works. This is because the compiler can infer the types of the arguments and then checks if they match the function signature.

You might be wondering, if a type parameter stands for any and all types why not just use the any type? The whole point of using a typed language is to have type safety in your code. Using any hides the type of the variable from the program and compiler, which can lead to undesired errors and behavior. Generics surface the types being used and make for a more type safe program.

Generics can also be used for classes and interfaces to make our lives much better.

Classes and Interfaces

Let’s say we want to make our own implementation of a stack in TypeScript. Like discussed before, a naive approach might be to use any for the elements of the underlying array, or to use one specific type for the array elements, but now that we have the power of generics in our hands we can do something like:

class Stack<T> {
    // `pop` and `peek` can return undefined if there are no items in
    // the stack. Hence why we are using the (T | undifined) type.
    // We will get a little more into this later.
    
    private arr: T[]

    constructor() {
        this.arr = []
    }
    
    pop(): T | undefined {
        return this.arr.pop()
    }
    
    push(item: T) {
        this.arr.push(item)
    }
    
    peek(): T | undefined {
        return this.arr[this.arr.length - 1] 
    }
}

The same principle of generics that we learned before applies here too. Now that we have a generic Stack class we can make stacks of all sorts of types.

const numberStack = new Stack<number>()
numberStack.push(1)
numberStack.peek() // => 1
numberStack.pop()  // => 1
numberStack.pop()  // => undefined

We can also use generics with interfaces. Let’s say we want an interface that defines a simple equality function. We can do the following:

interface Equality<T> {
    (a: T, b: T): boolean
}

With this we can define functions that adhere to the argument and return types defined  above.

const numberEquals: Equality<number> = (a, b) => {
    return a === b
}

// Lets define a Hotel type for another example.
class Hotel {
    name: string
    address: string

    constructor(name: string, address: string) {
        this.name = name
        this.address = address
    }
}

const hotelEquals: Equality<Hotel> = (a, b) => {
    return a.name === b.name && a.address === b.address
}

With the hotel example we can make it a little cleaner by making the hotelEquals function a static member of the class.

class Hotel {
    name: string
    address: string
    
    constructor(name: string, address: string) {
        this.name = name
        this.address = address
    }
    
    static equals: Equality<Hotel> = (a, b) => {
        return a.name === b.name && a.address === b.address
    }
}

const h1 = new Hotel("1", "3")
const h2 = new Hotel("2", "4")
const h3 = new Hotel("1", "3")

Hotel.equals(h1, h2) // => false
Hotel.equals(h1, h3) // => true

These  are simple examples, but  much more can be achieved with generics, and we are not limited to only one type parameter.

Multiple Type Parameters

Here's a little secret, we can use multiple type parameters at once and we can name them whatever we want. The type does not have to be T. We can use any type names we want like Jello, GenType, or A. The standard is to use single letters from the alphabet, but feel free to use something else if it makes more sense. Also, if we need three generics types in a class, we can just add three type parameters in the class signature. We can add any number of them we need. This opens up a much bigger world than what we have seen so far.

Let’s go back to our concat example and say that we now want to be able to concat two lists together of completely different types. With what we just learned we can do the following.

function twoTypedConcat<T, U>(first: T[], second: U[]): (T | U)[] {
    const arr: (T | U)[] = []
    first.forEach(item => arr.push(item))
    second.forEach(item => arr.push(item))
    return arr
}

twoTypedConcat([1, 2, 3], ["4", "5", "6"])  // => [1, 2, 3, "4", "5", "6"]

First thing to note here is the T | U type. This is a union type and means that the variable can be of T or U type. So (T | U)[] is the type for a list with elements that are either of type T or U. In this example we only had to change the type of arr to our union type and we are able to concatenate two lists of different types.

Generics are a very powerful tool that can help us keep our code DRY and make our lives as developers less painful. One thing that should be stressed though is that not everything needs to be generic. This tool  should only really be used for the purposes of not writing duplicate code and simplifying code. At the end of the day it is up to the developer to decide where and when to use it.

Hopefully this helped and you have a new tool in your arsenal to tackle challenges. Have fun!