Generics provide reusable bits of code that work with a number of types instead of a single type. Generics provide a way to treat type as a variable and specify it on usage, similar to function parameters.
Generics can be used in conjunction with functions (creating generic function), classes (creating generic class), and interfaces (creating generic interface).
Basic usage
You have probably used generics in the past even without knowing that – the most common usage of generics is declaring an array:
const myArray: string[];
It’s not too special at first glance, we just declaring myArray
as an array of strings, but it is the same as a generic declaration:
const myArray: Array<string>;
Keepin’ things explicit
Let’s start with very simple example – how could we port this vanilla JS function to TypeScript:
function getPrefiledArray(filler, length) {
return (new Array(length)).fill(filler);
}
This function will return array filled with given amount of filler
, so length
will be number
and whole function will return array of filler
– but what is filler? At this point it can be anything so one option is to use any
:
function getPrefiledArray(filler: any, length: number): any[] {
return (new Array(length)).fill(filler);
}
Using any
is certainly generic – we can pass literally anything, so “work with a number of types instead of a single type” from the definition is fully covered, but we lose the connection between filler
type and the return type. In this case, we want to return some common thing, and we can explicitly define this common thing as type parameter:
function getPrefiledArray<T>(filler: T, length: number): T[] {
return (new Array(length)).fill(filler);
}
and use like this:
const prefilledArray = getPrefiledArray<number>(0, 10);
Generic Constraints
Let’s look at different, probably more common cases. Why do we actually use types in functions? For me, it is to ensure that arguments passed to the function will have some properties that I want to interact with.
Once again let’s try to port simple vanilla JS function to TS.
function getLength(thing) {
return thing.length;
}
We have a nontrivial conundrum – how to ensure that the thing has a length
property, and first thought may be to do something like:
function getLength(thing: typeof Array):number {
return thing.length;
}
and depending on the context it might be correct, overall we are a bit generic – it’ll work with arrays of multiple types, but what if we don’t really know if the thing should always be an array – maybe the thing is a football field or a banana peel? In this case, we have to collect the common properties of that thing into a construct that can define properties of an object – an interface:
interface IThingWithLength {
length: number;
}
We can use IThingWithLength
interface as type of the thing
parameter:
function getLength(thing: IThingWithLength):number {
return thing.length;
}
quite frankly in this simple example, it’ll be perfectly fine, but if we want to keep this type generic, and not face the issue from the first example we can use Generic Constraints:
function getLength<T extends IThingWithLength>(thing: T):number {
return thing.length;
}
and use it:
interface IBananaPeel {
thickness: number;
length: number;
}
const bananaPeel: IBananaPeel = {thickness: 0.2, length: 3.14};
getLength(bananaPeel);
Using extends
ensures that T
will contain properties that are defined by IThingWithLength
.
Generic Classes
Up to this point, we were working with generic functions, but it’s not the only place where generics shine, let’s see how can we incorporate them into classes.
First of all let’s try to store bunch of bananas in the bananas basket:
class Banana {
constructor(
public length: number,
public color: string,
public ionizingRadiation: number
) {}
}
class BananaBasket {
private bananas: Banana[] = [];
add(banana: Banana): void {
this.bananas.push(banana);
}
}
const bananaBasket = new BananaBasket();
bananaBasket.add(new Banana(3.14, ‘red’, 10e-7));
Now let’s try to create general purpose basket, to different stuff with the same type:
class Basket<T> {
private stuff: T[] = [];
add(thing: T): void {
this.stuff.push(thing);
}
}
const bananaBasket = new Basket<Banana>();
And lastly, let’s assume that our basket is a radioactive material container and we can only store matter that has ionizingRadiation
property:
interface IRadioactive {
ionizingRadiation: number;
}
class RadioactiveContainer<T extends IRadioactive> {
private stuff: T[] = [];
add(thing: T): void {
this.stuff.push(thing);
}
}
Generic Interface
Lastly let’s try to gather all our knowledge and build radioactive empire also using Generic Interfaces:
// Define common attributes for containers
interface IRadioactive {
ionizingRadiation: number;
}
// Define something that is radioactive
interface IBanana extends IRadioactive {
length: number;
color: string;
}
// Define something that is not radioactive
interface IDog {
weight: number;
}
// Define interface for container that can hold only radioactive stuff
interface IRadioactiveContainer<T extends IRadioactive> {
add(thing: T): void;
getRadioactiveness():number;
}
// Define class implementing radioactive container interface
class RadioactiveContainer<T extends IRadioactive> implements IRadioactiveContainer<T> {
private stuff: T[] = [];
add(thing: T): void {
this.stuff.push(thing);
}
getRadioactiveness(): number {
return this.stuff.reduce((a, b) => a + b.ionizingRadiation, 0)
}
}
// ERROR! Type 'IDog' does not satisfy the constraint 'IRadioactive'
// And it’s kinda brutal to store dogs inside radioactive container
const dogsContainer = new RadioactiveContainer<IDog>();
// All good fam!
const radioactiveContainer = new RadioactiveContainer<IRadioactive>();
// Remember to sort your radioactive waste - create separate bin for bananas only
const bananasContainer = new RadioactiveContainer<IBanana>();
That’s all folks!