Typescript Generics
Typescript generics add a layer of abstraction to our code making it more reusable.
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 ]