Some programming languages, including C# and Java have long been using Generics in order to create reusable components for applications, now with TypeScript we can create such components as well.
So, what are these reusable components? Think about it this way: let's assume that we want to create some code that will allow us to randomly pick a number for a card game. This should be very easy to write:
function pickNumber(numbers: number[]): number {
const randomIndex = Math.floor(Math.random() * numbers.length);
return numbers[randomIndex];
}
const numbers = [...Array(13).keys()];
const pickedNumber = pickNumber(numbers);
console.log(pickedNumber);
Please note that in order for this code to compile properly with
tsc
, use the--target es6
flag, otherwise a bunch of errors will appear
Since we are working on a card game, we wouldn't only want to randomly pick a number but also a suit (diamonds, clubs, hearts and spades). At this point we'd have to create another function:
function pickSuit(suits: string[]): string {
const randomIndex = Math.floor(Math.random() * suits.length);
return suits[randomIndex];
}
const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const pickedSuit = pickSuit(suits);
console.log(pickedSuit);
Surely you notice that both of these functions effectively do the same but they work with different data types - and if we wanted to have another function to pick something else we'd have to create yet another function – this is not really an ideal solution.
With generics we can create a 'generic' function – a function that doesn't care about what data types we work with but it'll effectively accept any data type.
Those who are already familiar with TypeScript may be inclined to use the any
keyword and therefore recreating our functions would look like this:
function picker(args: any[]): any {
const randomIndex = Math.floor(Math.random() * args.length);
return args[randomIndex];
}
const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const numbers = [...Array(13).keys()];
const pickedNumber = picker(numbers);
const pickedSuit = picker(suits);
console.log(`Your card is: ${pickedNumber} ${pickedSuit}`);
Although this works and it seems to be a good solution there's a fundamental problem with it. We lose the data type information since we are using the any
datatype, which could create some potentially weird behaviour:
function picker(args: any[]): any {
const randomIndex = Math.floor(Math.random() * args.length);
return args[randomIndex];
}
const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const numbers = [...Array(13).keys()];
const pickedNumber = picker(numbers);
const pickedSuit: number = picker(suits); // pickedSuit is really a string
console.log(`Your card is: ${pickedNumber} ${pickedSuit}`);
In the above example we can see that pickedSuit
is cast as a number
but we know that suits should return a string
from an array of strings. This code compiles just fine and we will never see an error.
In order to maintain data types as well as to create reusable components we can come up with a generic function:
function picker<T>(args: T[]): T {
const randomIndex = Math.floor(Math.random() * args.length);
return args[randomIndex];
}
Generics are denoted by the angle brackets (<>
) and notice how the value specified in the angle brackets is used throughout the function signature.
Note that we can use any identifier for generics,
<T>
seems to be the de facto standard
So our picker() function takes an argument of any type and returns that type as well – so if we pass in an array of strings it'll return a string, if we pass in an array of numbers it'll return a number. Going back to the previous example where we tried to apply the wrong datatype to our variable, this time we'll receive an error:
const suits = ['diamonds', 'clubs', 'hearts', 'spades'];
const numbers = [...Array(13).keys()];
const pickedNumber = picker(numbers);
const pickedSuit: number = picker(suits); // Type 'string' is not assignable to type 'number'.
console.log(`Your card is: ${pickedNumber} ${pickedSuit}`);
The previously discussed examples worked just fine but there may be situations when TypeScript can't make assumptions on the type that gets passed in as an argument to a function. Let's consider an even simpler example:
function game<T>(args: T): T {
return args.name; // Property 'name' does not exist on type 'T'
}
In this case we have a game function where we'd like to pass in the name of the game and we want to return the name of the game. The problem that we have here is that it's perfectly valid to pass an array as the argument, just a string, or even a number and in all cases .name
property is not going to be accessible - TypeScript will also warn us. In fact, TypeScript is not able to foresee what arguments we'll pass in to our generic function but we can advise it.
Constraints can help us to constrain what data types or what entities are going to be passed to the generic function. In order to achieve this, we need to create an interface and let our generic know that it should extend it:
interface Game {
name: string;
}
function game<T extends Game>(args: T): string {
return args.name;
}
const myGame: Game = {
name: 'solitaire',
};
console.log(`My favourite game is : ${game(myGame)}`);
Since our generic function game()
now extends the interface Game we can easily access the name
property as well from the arguments to our function.