Typescript Generics

Typescript Generics add a layer of abstraction to our code making it more reusable. With generics we can work with a range of types rather than be restricted to use a single type. We can use generics to create reusable classes, interfaces, and functions.

Generics make our code well-defined, consistent and reusable.


Data type - any

To start off, let's talk about the type any as it can accept any type and it's certainly generic. However, this is not the best approach as it actually disables type-checking and compile-time checks.

function merge(objA: any, objB: any) { return Object.assign(objA, objB) } // TS will NOT throw an exception const student = merge({ name: "tory" }, 12)

In the above example, the merge function received a number as the second argument. However, Object.assign expects one or more source objects. The Typescript compiler doesn't know anything about it's members because we are using type any on both arguments, therefore it will silently ignore the bug.

Type any should be avoided and only be used when there are no other options.


Generic Function

Generics can capture information about the type of the argument the user provides. This type variable is generally defined with a <T>. We specify the the type when we call the function.

function merge<T, U>(objA: T, objB: U) { return Object.assign(objA, objB) } // throw an exception const student = merge<object, object>({ name: "tory" }, 12)

On this example, we are explicitly setting T and U to be type objects respectively. Typescript will throw an error because our second argument is a number and the merge function expects two objects.

Generic Constraints

We can apply Constraints to our generic types by using the extends keyword. Constraints are very flexible and can be any type. In this example, it makes sense to constrain to two objects because Object.assign expects two objects.

function merge<T extends object, U extends object>(objA: T, objB: U) { return Object.assign(objA, objB) } const student = merge({ name: "tory" }, { age: 12 })

Constraint Interfaces

In the following example, we want to ensure that our parameter has a length property without worrying about the exact type. We can accomplish this by creating an interface with the length property and extending it to the getLength function.

interface Lengthy { length: number } function getLength<T extends Lengthy>(args: T): number { return args.length } // throw an exception (error) let days = getLength(365) // Good let supplies = getLength(["pen", "notebook"])

The variable days will throw an exception because we are calling the getLength function with a number. The type number does not have a length property.

The variable supplies will work as expected because we are passing an array to the getLength function. And array has a length property.

Key of Constraint

We can also declare a type parameter that is constrained by another type parameter. For example, often we want to ensure that a key property exists on an object.

function getProperty<T, U extends keyof T>(obj: T, key: U) { return obj[key] } const user = { name: "tory", age: 12 } // throw an exception (error) let getFirstName = getProperty(user, "firstName") // Good let getName = getProperty(user, "name")

The getFirstName function will throw an exception because firstName doesn't exist on the user object. The getName function will work as expected because name is a property of the user object.

Generic classes

We can create generic classes to ensure that a specified data types are used consistently throughout the class.

The following example will throw an exception because we are not specifying the type of data that item is.

// throw an exception (error) class List { private list = [] addItem(item) { this.list.push(item) } removeItem(item) { const index = this.list.indexOf(item) if (index > -1) { this.list.splice(index, 1) } } getList() { return [...this.list] } }

Lets create a generic type parameter using the <T> to make sure all of the properties of the class are working with the same type.

// Good class List<T> { private list: T[] = [] addItem(item: T) { this.list.push(item) } removeItem(item: T) { const index = this.list.indexOf(item) if (index > -1) { this.list.splice(index, 1) } } getList() { return [...this.list] } }

Now, we can specify the type when we initialize the class. On groceryList instance we are specifying that <T> on the List class will be a type string.

const groceryList = new List<string>() groceryList.addItem("oranges") groceryList.addItem("apples") groceryList.removeItem("apples") groceryList.getList() // [ 'oranges' ]

We have the flexibility to change the type. For the numberList instance we are specifying that <T> on the List class will be a type number.

const numberList = new List<number>() numberList.addItem(2) numberList.addItem(3) numberList.getList() // [ 2, 3 ]