This site lists the protips that we shared with students during our courses
Generics is a very powerful feature of some languages that we use (not JavaScript). I’m going to use TypeScript here but will point out difference with C# etc.
Generics is a way for us to define methods that operates on different types in a similar way, but where we can decide the type at runtime.
If that made you go Huh?!
you’re like me. Let’s look at an example that TypeScript provides
class Person {
name!: string;
age!: number;
}
const people = Array<Person>();
people.push({ name: "Marcus", age: 49 });
console.log(people[0].name);
So, this is pretty cool, because I get a lot of help here. Array<Person>
is pronounced Array of Person
and that is exactly what it is - an array of Person. Nothing else. If I create another type and try to put it into array I will get an error:
class Product {
productName!: string;
price!: number;
}
const people = Array<Person>();
const product = new Product();
product.productName = "Euphonium";
product.price = 62000;
people.push(product); // ERROR
It error will be something like: Argument of type 'Product' is not assignable to parameter of type 'Person'..
. Basically - What the heck are you doing?! You can't put an Product into an array of Persons...
. Which is great because it will warn us very early if we mix up the types.
In short - with very little code we have created a typed array. But we can do more.
We can use generics in methods. Like this:
const getArrayLength = <T>(array: Array<T>) : number => {
return array.length;
}
const people = Array<Person>();
const products = Array<Product>();
console.log(getArrayLength<Person>(people))
console.log(getArrayLength<Product>(products))
This cool - because now we get a typed check of the length of the array. But it’s also stupid since we’re not using the type of anything. But let’s pause that thought for awhile.
Because this is still useful. If you write:
const products = Array<Product>();
console.log(getArrayLength<Person>(products)) // Argument of type 'Product[]' is not assignable to parameter of type 'Person[]'
Again - we get compile-time errors. Much good…
But, as I said, we could have written that method without using a generic parameter. Let’s create something useful out of this method - let’s write a method that returns name
from the type that we pass in.
This will be a bit of writing before we get there, but we will learn a lot.
Here’s my first stab:
const getNames = <T>(array : Array<T>): Array<string> => array.map(item => item.name);
This looks awesome but doesn’t. Property 'name' does not exist on type 'T'
is what it tells us. Let’s stop and think where - what type is T
? Well… TypeScript doesn’t know, because you will decide when you call it. In this function getNames
only knows that an Array<T>
will be passed to it. getNames
couldn’t know what that type is…
Imagine that I would pass this in for example:
class Product {
productName!: string;
price!: number;
}
const products = Array<Product>();
// adds a lot of products
const productNames = getNames<Product>(products);
Well that would not work… Product
doesn’t have a .name
property. But we could create an interface
for things with names and use that as our type parameter. Let’s do it - one step at the time:
interface INameable {
name: string;
}
class Person implements INameable {
name!: string;
age!: number;
}
Ok - I just said that Person
implements the INameable
interface, i.e. it has a name
property. Let’s now express that the T
, that we will pass, will implement INameable
. Like this:
const getNames = <T extends INameable>(array: Array<T>) => {
return array.map(item => item.name);
}
const people = Array<Person>();
const products = Array<Product>();
// adds a lot of items
const peopleName = getNames<Person>(people);
const productNames = getNames<Product>(products);
// Type 'Product' does not satisfy the constraint 'INameable'.
// Property 'name' is missing in type 'Product' but required in type 'INameable'
Whoho - this is great. We now got an error from products (since it doesn’t implement INameable
) and we’ve written one powerful function that get’s the type decided at runtime.
But wait - we can be even cooler.
Before we start this section - what we are about to go through now is very geeky. It MIGHT be useful but is mostly a Hey - look at this cool way of using generics
.
We can actually create instances of a T
type parameter. It’s super-strange, but actually the other day we ran into a problem that could be solved using this approach.
Let’s say that you want to read information from a bunch files and create different type of objects depending on which file it is.
// people.csv
id, name, age
1, Marcus, 49
2, Ossian, 32
// products.csv
id, name, price, type
1, Euphonium, 62000, musical instrument
2, Iphone, 8000, phone
We want to create a method that can give me an Array<T>
where the T
is Person
or Product
. We can do it, equipped with generics.
First - let’s get the file reading out the picture with this little method that reads the files above to arrays per row, splitted on ,
const readFile = (path:string) : string[][] =>
readFileSync(path, "utf8")
.toString()
.split('\n')
.filter(r => r !== '')
.map(r => r.split(','))
.filter((_, i) => i !== 0);
console.log(readFile('./people.csv'));
/*
[ [ '1', ' Marcus', ' 49' ], [ '2', ' Ossian', ' 32' ] ]
*/
That is cool, but just strings. I want types! Array<Person>
and Array<Product>
respectively, and write a generic method for that.
Here’s my first try (psst - this doesn’t work - but bear with me)
const
But now, let’s write a method that takes that data and turns into an array
const fileDataToArray = <T>(path:string) : Array<T> => {
const fileData = readFile(path);
return fileData.map(r => new T(r))
}
A lot of this actually looks very reasonable. And it’s not far off. In fact, if this would have been C#
we could just about exactly have written the above. Here’s how it looks:
public Array<T> FileDataToArray(string path) where T : new()
{
var a = new T();
}
See that last part where T : new()
. This is a type constraint which limits what types we can put on it. We’ve used it before when we wrote <T extends INameable>
to say that only T
that implements INameable
is allowed.
The C#
constraint where T : new()
means that the T
passed to the method needs to have a public constructor. We are even using it in the method var a = new T();
. Pretty cool - because now we can pass any T
and have it created.
But this present us with two problems for our case. First - how do I express that in TypeScript. Secondly – we need to pass parameters to the constructor, namely the data from each row of the file.
This can be expressed in TypeScript but I have to say that it is a bit clunky. But hey - in C#
you cannot express it at all (only parameterless constructors are allowed as type constraints). Here’s the TypeScript code:
const fileDataToArray = <T>(
path:string,
type: { new (data: string[]): T }
) : Array<T> => {
const fileData = readFile(path);
return fileData.map(row => new type(row))
}
That requires some careful reading. Let’s do it, line by line:
fileDataToArray
. It has a type parameter of T
, no constraints.path
, a stringtype: { new (data: string[]): T }
) is a bit weird but needed by TypeScript when creating factories like this.
new
method, aka a constructor.new
that is needs a string[]
as parameter.new
to return a T. Hence the type
parameter and the T
has to align or we will get a complication error.T
s back.new type(row)
which makes use of the second parameter to the function.Once we have done this we are just about done, because there’s one step left – we need to update the classes to actually have constructors that works on string[]
. Here we go:
class Person {
name!: string;
age!: number;
constructor(data:string[]){
this.name = data[1]
this.age = Number.parseInt(data[2])
}
}
class Product {
productName!: string;
price!: number;
type!: string;
constructor(data:string[]){
this.productName = data[1]
this.price = Number.parseInt(data[2])
this.type = data[3]
}
}
It feels proper that the classes knows how to create new instances from the string[]
. We could also have an empty constructor if we wanted, but our example doesn’t use that so I skipped it.
And finally we can now call our fileDataToArray
function like this:
fileDataToArray<Person>('./people.csv', Person)
.map(p => console.log(`${p.name} - ${p.age}`))
fileDataToArray<Product>('./products.csv', Product)
.map(p => console.log(`${p.productName} (${p.type}) costs ${p.price}`))
It works. And it’s amazingly cool to see how the p
in .map
is typed to the correct type and and that type is propagated from our generic fileDataToArray
method.
Generics is amazingly cool and you can write code once to solve problems for many types. It can get out of hand though and the example at the end might be pushing the limits of what is easy to understand. But it works and is very flexible, with little code.
I hope you enjoyed this. I sure enjoyed writing it.
Read more about TypeScript generics here