Generics in TypeScript

Arun Kant Pant
Level Up Coding
Published in
11 min readJan 11, 2021

--

Generics definitely seem intimidating to anyone who is not used to them. In this blog post, I will try to make them a little less mysterious, and show how they can be useful by using a more hands-on approach.

You can follow along over at Typescript playground, if you do not have any local environment set up. I would recommend typing along the examples in a separate tab.

The first glimpse

Generics, as the name implies, help us create more reusable chunks of code.

In simple words:
They can help us create well defined classes, interfaces, and functions while still giving us the flexibility of keeping them “generic”- by making them work according to a passed type (or types) by the programmer.

Let us look at this with our first example, you can type this out in the Typescript Playground:

function iTakeAnyArrays(arr: any[]): any[] {
return arr;
}
iTakeAnyArrays(1); //error function iTakeGenericArrays<T>(arr:T[]): T[] {
return arr;
}
iTakeGenericArrays(1); //error
For now think of the <T> here as a kind of paceholder which will be replaced by a type passed in by the user (or a type that can be inferred from the arguments). As we proceed this will become clearer.

We have intentionally passed a wrong argument to our functions in the above example, the argument is of course expected to be an array. This is to have a look at what might be going on behind the scenes.

If you hover over the errors on your editor (or online playground):

iTakeAnyArrays(1):
Argument of type 'number' is not assignable to parameter of type 'any[]'
iTakeGenericArrays(1):
Argument of type 'number' is not assignable to parameter of type 'unknown[]'

If you do not know about the difference between any and unknown, just know that unknown will narrow down to an inferred type whenever it can be inferred from the code and then stick to that type.

Here is more on this.

However “any” will never narrow down to a type, and will always be arbitrary.

This is why “any” might not be ideal for generalising pieces of your code sometimes, as you lose some (or a lot) of the type definition.

If we now try to pass the correct type of args:

function iTakeAnyArrays(arr: any[]): any[] {
return arr;
}
iTakeAnyArrays([1]);function iTakeGenericArrays<T>(arr:T[]): T[] {
return arr;
}
iTakeGenericArrays([1]);

The errors are of course gone, and the more interesting part is again when you hover over the following (I have commented what you will see in the IDE, on hover):

iTakeAnyArrays([1]);
// function iTakeAnyArrays(arr: any[]): any[]
iTakeGenericArrays([1]);
// function iTakeGenericArrays<number>(arr: number[]): number[]

If we further modify the code to take different kinds of arrays, and hover over the function invocations:

function iTakeAnyArrays(arr: any[]): any[] {
return arr;
}
iTakeAnyArrays([1]);
// function iTakeAnyArrays(arr: any[]): any[]
iTakeAnyArrays(['A']);
// function iTakeAnyArrays(arr: any[]): any[]

function iTakeGenericArrays<T>(arr:T[]): T[] {
return arr;
}
iTakeGenericArrays([1]);
// function iTakeGenericArrays<number>(arr: number[]): number[]
iTakeGenericArrays(['B']);
// function iTakeGenericArrays<string>(arr: string[]): string[]

You will see that our function iTakeGenericArrays adjusts the definition according to the type that can be inferred from the arguments.

The function iTakeGenericArrays is what is called a generic, as it now works over a range of types.

It was possible to make it so by using “T” in the function definition:

function iTakeGenericArrays<T>(arr:T[]): T[]

This is called the type variable, and is used to simply pass in a type. This variable only works to pass in types and not values. You need not neccessarily use a “T” for the variable name and can use any other variable of your choice.

So far we did not explicitly pass in any type, and it was inferred automatically by the TypeScript compiler from the args.

We can explicitly pass in types when required. We have modified our previous generic function a little to now return the length of the passed in array:

function iTakeGenericArrays<T>(arr:T[]): number {
return arr.length;
}
// T will be replaced by type string:let arrLength = iTakeGenericArrays<string>(['B']);
console.log(arrLength);
// The following will give an error:arrLength = iTakeGenericArrays<string>([2]);
console.log(arrLength);
// Type 'number' is not assignable to type 'string'

We had used the function parameter arr:T[] to get to this point, so that we could access the .length property of arrays.

You might wonder why could we not just use the following, replacing T with something like string[] directly:

function iTakeGenericArrays<T>(arr:T): number {
return arr.length;
}
// T will be replaced by string:
let arrLength = iTakeGenericArrays<string[]>(['B']);
console.log(arrLength);
Here we have writteniTakeGenericArrays<T>(arr:T): numberinstead ofiTakeGenericArrays<T>(arr:T[]): numberto see if we can directly pass in string[] into T, and if our compiler will accept it

But this will give the error:

...
return arr.length;
...
Property 'length' does not exist on type 'T'.

Well, typescript logs this error because it has to make sure that all types are passed in consistently.

If someone passes the type number into the type variable, then obviously the .length property will not be accessible on it. Therefore it ensures that our function parameters are correctly defined to suit all types that could be passed in.

Using multiple type parameters

We can also use multiple type variables in our generic function. This can be done simply as follows:

function multipleGenericTypes<T,K>(arg1: T, arg2: K): boolean {
return typeof arg1 === typeof arg2;
}
const res = multipleGenericTypes<string, string>('a', 'b');
console.log(res); // true
const anotherRes = multipleGenericTypes<string, number>('1', 1);
console.log(anotherRes); //false

We simply pass the additional type variable as <T, K> and use it as usual in our args.

Generics with constraints

Okay so far, we have seen that our type variables can accept all types of data and the code that we write using this type variable also has to conform to being compatible with all types.

We saw this earlier, when we tried to access .length on a type variable which could also very well have been a number instead of an array.

This can be a disadvantage in some cases where, for example, we know that our function will deal with a range of types.

A Practical Example

Let us look at this with a practical application. Gaming is one of my favourite hobbies and so our example will deal with something related to it.

Imagine we have a scenario where a character attacks another character. Damage will be inflicted on the victim. A common case with realistic games is that, the items used by characters also degrade with use.

We could therefore use a common function to inflict damage on a character, and then reuse the same function to reflect the degradation on the weapon as a result of this attack.

We will therefore need to limit our generic function to accept only a range of types where we can reflect some kind of damage to a particular stat. A common stat seen in gaming is an hp (health point) stat.

We will therefore try to create a generic function that does accepts any types which posses an hp property.

For this, let us first create an interface:

interface CanTakeDamage {
hp: number;
}

This interface defines a type for any entities in our game that can have an hp property.

After this, let us define the classes for our Characters and the Weapons they might use. The Weapons will have some common properties with the Characters:

class Character {
name: string;
attack: number;
hp: number;
constructor(
name: string,
attack: number,
hp: number,
) {
this.name = name;
this.attack = attack;
this.hp = hp;
}
}
class Weapon extends Character {
price: number;
constructor(
name: string,
attack: number,
hp: number,
price: number,
) {
super(
name,
attack,
hp
);

this.price = price;
}
}

So both of these entities will have a name, a base attack stat and the hp stat. The Weapon will additionally have a “price” property.

Let us now obtain three such gaming Entities: a witcher, a Bruxa that the witcher will attack and a Sword that he will use to attack.
The Witcher is “Geralt”, the Bruxa is “Lily” and the Sword is the great “Aerondight”.

const Witcher = new Character(
"Geralt",
1250,
3200
);
const Bruxa = new Character(
"Lily",
1450,
4200
);
const SilverSword = new Weapon(
"Aerondight", // name
550, // base attack
500, // hp property used here for sword "health"
5500 // price
);

We pass to each their respective stats along with their name.

Great, now all that is left to do is create a generic function that will help us inflict damage on these gaming entities.

This function can use a type variable “T”, but how do we narrow the range of this type variable to ensure that only something having an hp property is deemed a valid type?

This can be done using the interface we defined earlier, by having our type variable extend this interface:

interface CanTakeDamage {
hp: number;
}
...function inflictDamage<T extends CanTakeDamage>(
victimEntity: T,
damage:number
) {
return victimEntity.hp - damage;
}

This way we can narrow down our range of acceptable types to only thos which can pass as CanTakeDamage.

The inflictDamage function above will only accept args for the victimEntity parameter which will be of type CanTakeDamage i.e. they will have the hp property present on them.

This is because we specified that whatever type is passed into T, should extend CanTakeDamage.

<T extends CanTakeDamage>

Had we not extended this interface, we could have passed any arbitrary type and would get an error from the compiler about the hp property not being present on T. Since T could be given any other type like string or number.

Now, all that is left to do is test out our function. To the code that we have so far, we will add the following:

...const damageBruxa = inflictDamage(
Bruxa,
Witcher.attack + SilverSword.attack
);
const damageSword = inflictDamage(
SilverSword,
Bruxa.hp/50
);
console.log(`
Bruxa now has ${damageBruxa} hp,
Sword degrades to ${damageSword} points
`);

Which will log:
Bruxa now has 2400 hp,
Sword degrades to 416 points

Your complete code should look like the following:

While we are on the topic of interfaces and generics, let’s next have a look at something which brings both together even closer.

Generic Interfaces

Suppose we have a function to check the length of a passed array:

function checkLength<T>(item: T[]): number {
return item.length;
};

We have another function, where we require to pass this as a callback. This other function will also take a string as its second argument, and will use the callback to return the length of this string.

Something along the lines of the following, but with a missing piece:

function checkLength<T>(item: T[]): number {
return item.length;
}
function getStrLength(
cb: ??????,
str: string
): number {
return checkLength(str.split(''));
}
const len = getStrLength(
checkLength,
'The Lesser Evil.'
);
console.log(len);

The question marks have been put deliberately, just what is the type that we can pass for our callback checkLength function?

Looking at our checkLength function, we can write the type for the callback as:

function checkLength<T>(item: T[]): number {
return item.length;
}
function getStrLength(
cb: <T>(item: T[]) => number,
str: string
): number {
return cb<string>(str.split(''));
}
const len = getStrLength(
checkLength,
'The Lesser Evil.'
);
console.log(len); // 16

Our function is of the type:

checkLength: <T>(item: T[]) => numbersince it takes in an item parameter of an array of any T type of elements, and returns a number which is the length of the array.

On running the code we get the expected result.

Now suppose we have another case where we need to check the difference in length between two strings.

Again, using a passed callback and the two strings to check against each other.

We can reuse our checkLength method for the callback part, to come up with a getStrDiff function. Something like the following:

function checkLength<T>(item: T[]): number {
return item.length;
};
function getStrLength(
cb: <T>(item: T[]) => number,
str: string
): number {
return cb<string>(str.split(''));
}
function getStrDiff(
cb: <T>(item: T[]) => number,
str1: string,
str2: string
): number {
return (
cb<string>(str1.split('')) - cb<string>(str2.split(''))
);
}
const len = getStrLength(
checkLength,
'The Lesser Evil.'
);
const diff = getStrDiff(
checkLength,
'Silver for Monsters',
'Steel for Humans'
)
console.log(len, diff); // 16, 3

Note how we had to write out the entire type for the callback again. This should certainly be avoidable.

And this problem can be solved using an interface. We can define one for the type of this common callback:

interface CheckLength {
<T>(item: T[]): number
}
(Note the capital "C", this is not the same as our checkLength function earlier)

We can now use this interface to define the callback type in our function parameters for both getStrDiff and getStrLength:

interface CheckLength {
<T>(item: T[]): number
}
function checkLength<T>(item: T[]): number {
return item.length;
};
function getStrLength(
cb: CheckLength,
str: string
): number {
return cb<string>(str.split(''));
}
function getStrDiff(
cb: CheckLength,
str1: string,
str2: string
): number {
return (
cb<string>(str1.split('')) - cb<string>(str2.split(''));
);
}
const len = getStrLength(
checkLength,
'The Lesser Evil.'
);
const diff = getStrDiff(
checkLength,
'Silver for Monsters',
'Steel for Humans'
)
console.log(len, diff);

We can also pass a type variable to the interface itself, to further refine our code:

interface CheckLength<T> {
(item: T[]): number
}
function checkLength<T>(item: T[]): number {
return item.length;
};
function getStrLength(
cb: CheckLength<string>,
str: string
): number {
return cb(str.split(''));
}
function getStrDiff(
cb: CheckLength<string>,
str1: string,
str2: string
): number {
return (
cb(str1.split('')) - cb(str2.split(''));
);
}
const len = getStrLength(
checkLength,
'The Lesser Evil.'
);
const diff = getStrDiff(
checkLength,
'Silver for Monsters',
'Steel for Humans'
)
console.log(len, diff);

How is this different from the code before? Well, earlier the only advantage we had was making the type definition for the cb parameter more concise.

Earlier, we still had to write out:

function getStrLength(
cb: CheckLength,
str: string
): number {
return cb<string>(str.split(''));
}
function getStrDiff(
cb: CheckLength,
str1: string,
str2: string
): number {
return (
cb<string>(str1.split('')) - cb<string>(str2.split(''));
);
}

Whereas now,

function getStrLength(
cb: CheckLength<string>,
str: string
): number {
return cb(str.split(''));
}
function getStrDiff(
cb: CheckLength<string>,
str1: string,
str2: string
): number {
return (
cb(str1.split('')) - cb(str2.split(''));
);
}

We are enforcing the type T to be string for the callback cb, with the help of the interface itself.

Here is the complete code:

To wind up, let us have a look at how we can use generics with classes as well, with another practical application.

Generic Classes

If you have understood the concepts so far, understanding generic classes should be a breeze.

Just like with interfaces, we simply add the type variable after the class declaration:

class Character<T> {
private name: T;
sayMessage: (msg: T) => T;
constructor(
name: T,
sayMessage: (msg: T) => T
) {
this.name = name;
this.sayMessage = sayMessage;
}
sayName = (): T => {
return this.name;
};
}

We can then create a Character object as follows:

const Geralt = new Character<string>(
'Geralt of Rivia',
(msg) => {
return msg
}
);

T in this case will take in string as its type, and the sayMessage method on our Geralt object will therefore only accept strings as its msg argument.

const geraltSays = Geralt.sayMessage(`What is up my dude, I am ${Geralt.sayName()}`);
console.log(geraltSays);
Will log
"What is up my dude, I am Geralt of Rivia"
Whereasconst geraltSays = Geralt.sayMessage(10);
console.log(geraltSays);
// will give an error since 10 is not of type string

The complete code looks like this:

And that should about cover most of what you need to know to get started with generics.

I would recommend checking out the Typescript Handbook to get to know more about generics and typescript in general.

Hope this helped in some way at least to understand generics better!

--

--

Building things, breaking things, learning things. Frontend Engineer at The Economist Intelligence Unit.