Generics page

Learn about what Generics are, why they are useful, and how to create a linked list using Generics in TypeScript.

Overview

In this section, you will:

  • Understand the fundamentals and objectives of generic functions.
  • Create generic classes.
  • Combine recursion with generic classes.
  • Create recursive generics.

Objective 1: The basics of generics

The problem generics solve

Generics are a way of writing abstract code that allows the determination of types to be handled when the code is used. Generics let us reuse code for different types and improve maintainability. Let’s see how with a small example.

Consider a function that wraps a value in an object:

function wrapAsValue(value) {
    return {value: value};
}

Ideally, you’d want to use this function to wrap all sorts of values:

const fourWrapped = wrapAsValue(4);  //-> {value: 4}
const hiWrapped = wrapAsValue("hi"); //-> {value: "hi"}

And you might want to pass those objects to other functions:

function getDollars(object: {value: number}){
    return "$" + object.value.toFixed(2)
}

function getMessage(object: {value: string}) {
    return object.value + " world";
}

getDollars(fourWrapped); //-> "$4.00"
getMessage(hiWrapped);   //-> "hi world"

But watch out! The following will not error until runtime because strings do not have a toFixed() method.

getDollars(hiWrapped);  

You don’t see a compile time error because hiWrapped object looks like {value: any} to TypeScript.

Getting a compile time error can be solved in a variety of inelegant ways:

Way 1: Define the type of the variables:

const fourWrapped: {value: number} = wrapAsValue(4);
const hiWrapped:   {value: string} = wrapAsValue("hi");

The main drawback here is the redundancy and verbosity introduced by having to manually specify the type of each variable. This approach lacks scalability as every new variable type requires explicit type declaration, which can be tedious and error-prone, especially in larger codebases where the number of variable types can increase significantly.

Way 2: Write multiple functions:

function wrapStringAsValue(value: string) {
  return {value: value};
}
function wrapNumberAsValue(value: number) {
  return {value: value};
}

This approach suffers from significant code duplication, with each function wrapping a different type of value in the same manner, which violates the DRY (Don’t Repeat Yourself) principle. Additionally, this method adds a maintenance burden. For every new type that needs to be wrapped, a new function must be created, which can lead to increased code complexity and potential inconsistencies in function implementation across different types.

Way 3: Overload wrapAsValue signatures:

function wrapAsValue(value: string): {value: string};
function wrapAsValue(value: number): {value: number};
function wrapAsValue(value: any) {
    return {value: value};
}

The use of function overloading here introduces complexity by requiring you to manage multiple function signatures, complicating both the use and documentation of the function. Additionally, the implementation leverages the any type for a catch-all method, which undermines TypeScript’s robust type-checking by allowing any type to be passed, potentially leading to runtime errors that are difficult to detect during compilation, thereby reducing the effectiveness of using TypeScript.

Introducing generics

With generics, this problem can be solved more simply:

function wrapAsValue<MyType>(value: MyType): {value: MyType} {
    return {value: value};
}

const fourWrapped = wrapAsValue<number>(4);
const hiWrapped = wrapAsValue("hi");

function getDollars(object: {value: number}){
    return "$"+object.value.toFixed(2)
}

function getMessage(object: {value: string}) {
    return object.value + " world";
}

getDollars(fourWrapped);
getMessage(hiWrapped);
getDollars(hiWrapped);

The <MyType> part of the wrapAsValue definition is the Generics part. This <MyType> allows us to capture the type the user provides so that we can use that information later. In this case, we are using it to specify that the return type is an object with a MyType value property ({value: MyType}). This allows us to traffic that type of information in one side of the function and out the other.

Calling generic functions

We can call generic functions in two ways.

First, we can explicitly pass the type:

 wrapAsValue<number>(4)

Notice that <number> acts as a special set of arguments. Instead of arguments passed like func(arg1, arg2, arg3), generic type arguments are passed like func<Type1, Type2, Type3>.

Second, the type can be inferred:

wrapAsValue("hi")

Notice that we didn’t explicitly pass the type n the angle brackets (<>). Instead, the compiler just looked at the value "hi" and set MyType to string.

Setup 1

✏️ Create src/generics/last.ts and update it to be:

export function returnLast(array) {
  return array[array.length - 1];
}

Verify 1

✏️ Create src/generics/last.test.ts and update it to be:

import { returnLast } from "./return-last";
import { describe, it } from "node:test";
import assert from 'node:assert/strict';

describe("Generics", () => {
  it("returns last element with a string", () => {
    const lastString = returnLast<string>(["A", "B", "C"]);

    assert.equal(typeof lastString, "string", "It returns a string");
  });
  it("returns last element with a number", () => {
    const lastNumber = returnLast<number>([1, 2, 3]);

    assert.equal(typeof lastNumber, "number", "It returns a string");
  });
});

Exercise 1

Update the last.ts file to inform the function that it will be accepting an array of a certain type and return a single element of the same type.

Have issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 1

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

✏️ Update src/generics/last.ts to be:

export function returnLast<T>(array: T[]): T {
  return array[array.length - 1];
}

We use <T> to set up the generic. In the parenthesis, we use T[] to inform the user we are accepting an array of a certain type. Finally, we use ): T{ to let us be aware what is the return type.

Have issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Objective 2: Generics in classes

Using generics with classes

Generic classes are quite common.

const cardNumber = new Subject<string>();

cardNumber.next("1234")

In the example above, RxJS subjects are a generic class that can publish values of a particular type.

Let’s look at making a basic class to collect a list of things.

class Collection {
  private list: any[] = [];
  push(thing) {
    this.list.push(thing);
  }
}

const myList = Collection();
myList.push(25);
myList.push('25');

The good - we can push any type to this list.
The bad - we can push any type to this list.

myList now holds an assortment of types and will be a likely source of runtime errors.

Let’s build a generic Collection class instead.

class GenericCollection<T> {
  private list: T[] = [];
  pushItem( thing:T ) {
    this.list.push(thing);
  }
}

Now when we initialize this class we can specify a type to use.

class GenericCollection<T> {
  private list: T[] = [];
  pushItem(thing:T) {
    this.list.push(thing);
  }
}

const myListOfStrings = new GenericCollection<string>();
myListOfStrings.pushItem('booop');
myListOfStrings.pushItem(5);
// Error Argument type of '5' is not assignable to parameter of type 'string'

const myListOfNumbers = new GenericCollection<number>();
myListOfNumbers.pushItem(5);
myListOfNumbers.pushItem('boop');
// Error Argument type of '"boop"' is not assignable to parameter of type 'number'

interface Dinosaur {
  name: string;
  breed: string;
  teeth: number;
}

const myListOfDinosaurs = new GenericCollection<Dinosaur>();
const otherDino = {
  name: 'Blue',
  breed: 'Velociraptor',
  teeth: 100
}

myListOfDinosaurs.pushItem(otherDino);

myListOfDinosaurs.pushItem({name: 'Charlie'});
// Error Argument type '{ name: string; }' is not assignable to parameter of type 'Dinosaur'.

In the example above, we are utilizing generics to inform GenericCollection what type it is receiving: string, number, or Dinosaur.

A great example of the power of generics is creating a recursive data structure like a tree. In the following exercise, we will create a TreeNode class that can house a generic value and be used to create a tree structure of left and right nodes of the same generic type.

Setup 2

✏️ Create src/generics/tree.ts and update it to be:

interface Comparison<T> {
  (v1: T, v2: T): number;
}

class TreeNode {
  value: any;
  compare: Comparison<any>;
  left?: TreeNode;
  right?: TreeNode;

  constructor(val, compare: Comparison<any>) {
    this.value = val;
    this.compare = compare;
  }

  add(val) {
    if (this.compare(this.value, val) >= 1) {
      if (this.left == null) {
        this.left = new TreeNode(val, this.compare);
      } else {
        this.left.add(val);
      }
    } else {
      if (this.right == null) {
        this.right = new TreeNode(val, this.compare);
      } else {
        this.right.add(val);
      }
    }
  }
}

export default TreeNode;

Verify 2

✏️ Create src/generics/tree.test.ts and update it to be:

import TreeNode from "./tree-node";
import { describe, it } from "node:test";
import assert from 'node:assert/strict';

describe("Generics", () => {
  it("TreeNode can add numbers", () => {
    function numberComparison(v1: number, v2: number) {
      return v1 - v2;
    }

    const root = new TreeNode<number>(100, numberComparison);

    root.add(50);

    assert.equal(root.left?.value, 50, "50 to the left of 100");

    root.add(150);
    root.add(125);

    assert.equal(root.right?.value, 150, "150 to the right of 100");
    assert.equal(root.right?.left?.value, 125, "125 to the left of 150");
  });

  it("TreeNode can specify string", () => {
    function stringComparison(v1: string, v2: string): number {
      if (v1 > v2) {
        return 1;
      } else {
        return -1;
      }
    }

    const root = new TreeNode<string>("Jennifer", stringComparison);

    root.add("Taylor");

    assert.equal(root.left?.value, "Taylor", "Taylor to the left of Jennifer");

    root.add("Tom");
    root.add("Matthew");

    assert.equal(root.right?.value, "Tom", "Tom to the right of Jennifer");
    assert.equal(
      root.right?.left?.value,
      "Matthew",
      "Matthew to the left of Tom"
    );
  });
});

Exercise 2

Update the tree.ts file to create a recursive TreeNode class that can house a value and be used to create a tree structure of left and right nodes.

For example, we will be able to create a TreeNode with a root value and comparison function as follows:

import TreeNode from "./tree-node";
import { describe, it } from "node:test";
import assert from 'node:assert/strict';

describe("Generics", () => {
  it("TreeNode can add numbers", () => {
    function numberComparison(v1: number, v2: number) {
      return v1 - v2;
    }

    const root = new TreeNode<number>(100, numberComparison);

    root.add(50);

    assert.equal(root.left?.value, 50, "50 to the left of 100");

    root.add(150);
    root.add(125);

    assert.equal(root.right?.value, 150, "150 to the right of 100");
    assert.equal(root.right?.left?.value, 125, "125 to the left of 150");
  });

  it("TreeNode can specify string", () => {
    function stringComparison(v1: string, v2: string): number {
      if (v1 > v2) {
        return 1;
      } else {
        return -1;
      }
    }

    const root = new TreeNode<string>("Jennifer", stringComparison);

    root.add("Taylor");

    assert.equal(root.left?.value, "Taylor", "Taylor to the left of Jennifer");

    root.add("Tom");
    root.add("Matthew");

    assert.equal(root.right?.value, "Tom", "Tom to the right of Jennifer");
    assert.equal(
      root.right?.left?.value,
      "Matthew",
      "Matthew to the left of Tom"
    );
  });
});

Then we can add values to root like:

root.add("Taylor");

This will add Taylor to a left TreeNode of root because the stringComparison will return 1 (Jennifer > Taylor):

root.left.value //-> "Taylor"

As we add other values, they will be added to either the right or left nodes recursively:

root.add("Tom");
root.add("Matthew");

root.right.value      //-> "Tom"
root.right.left.value //-> "Matthew"

Have issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 2

If you’ve implemented the solution correctly, the tests will pass when you run npm run test!

Click to see the solution

✏️ Update src/generics/tree.ts to be:

interface Comparison<T> {
  (v1: T, v2: T): number;
}

class TreeNode<T> {
  value: T;
  compare: Comparison<T>;
  left?: TreeNode<T>;
  right?: TreeNode<T>;

  constructor(val: T, compare: Comparison<T>) {
    this.value = val;
    this.compare = compare;
  }

  add(val: T) {
    if (this.compare(this.value, val) >= 1) {
      if (this.left == null) {
        this.left = new TreeNode(val, this.compare);
      } else {
        this.left.add(val);
      }
    } else {
      if (this.right == null) {
        this.right = new TreeNode(val, this.compare);
      } else {
        this.right.add(val);
      }
    }
  }
}

export default TreeNode;

The use of generics in line 5 allows the TreeNode class to be flexible and reusable, accommodating different types of data and comparison logic.

Have issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Next steps

Next, let’s take a look at utility types for type transformations.