protips

Logo

This site lists the protips that we shared with students during our courses

View the Project on GitHub appliedtechnology/protips

Using generics

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.

What is generics?

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.

Generic methods

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…

Actually doing something useful with generics

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.

Creating a generic object

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:

  1. We are declaring a method called fileDataToArray. It has a type parameter of T, no constraints.
  2. The first parameter is the path, a string
  3. The second parameter (type: { new (data: string[]): T }) is a bit weird but needed by TypeScript when creating factories like this.
    • Basically we are saying that there will be a type that needs to have a new method, aka a constructor.
    • But the cool part is that we can also specify the parameters that is required. We tell our new that is needs a string[] as parameter.
    • Notice that we declare the new to return a T. Hence the type parameter and the T has to align or we will get a complication error.
  4. We then indicate that we will get an array of Ts back.
  5. The code inside the function is just about the same, with the difference that we do 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.

Summary

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