If you work in web development, there is almost no chance you have not ever heard about TypeScript — the strictly typed superset of JavaScript. Chances are you even (willingly or not) learned at least the basics of this language. Most of the major frameworks like Angular, React and Vue offer you the ability to write your projects in TS rather than plain old JS. But Angular was the first one to massively adopt it, so, for Angular developers, knowing TS as well as possible is a must.

I won’t be listing the benefits of using TS, or the basics of the language. Instead, I am going to focus on some patterns that often mislead developers writing code in TS, and help everyone utilize the power of static typing better.

Understanding the connections between types#

Consider this simple example:

interface Company {
    name: string;
}

class Vehicle {
    manufacturer: Company = {} as Company;
    name: string = '';
}

class Car extends Vehicle {
    engineType: 'Electric' | 'Internal Combustion' | 'Hybrid' = 'Internal Combustion'; 
}

class Bicycle extends Vehicle {
    numberOFWheels: 1 | 2 | 3 | 4 = 2;
}

Here we have a base class Vehicle, and two subclasses, Car and Bicycle. They don’t have much properties, so it’s easy to grasp their meaning and purpose.

Now imagine we want to write a function that will take a Car or a Bicycle, and append it to an array of Cars or Bicycles. Because they both extend from Vehicle, we need a function that takes an array of Vehicles and a Vehicle and appends it to the former:

function appendToVehicles(vehicles: Vehicle[], vehicle: Vehicle) {
    vehicles.push(vehicle);
}

So far so good. Our function is strictly typed, so we cannot push a number, for example, into the array of Vehicles. But there still a pitfall. Do you see it? If not, take a look at this example usage of our

const cars: Car[] = [];

appendToVehicles(cars, new Car());
appendToVehicles(cars, new Bicycle());

console.log(cars);

Here, we take Bicycle and try to push it into an array of Car instances. “No way this gonna work!” you’re probably thinking now.

Spoiler: way!

The code compiles successfully, TS compiler does not throw any warnings whatsoever, and if we open the console we will see the following:

How is this even possible?!

So, now we have an array of Cars that has a Bicycle in it. But what went wrong? Did TS fail us? Should we go open an issue on the github repo?

No, no. In reality, what happened is that we failed to tell TS exactly what we want. We told TS to create a function which accepts an array of Vehicles an another Vehicle, and then append the latter to the former. Which TS did masterfully — I can only pass an array of Vehicles and a Vehicle to it, and nothing more. Which I did — both Cars and Bicycles are, in fact, Vehicles. But what I really wanted TS to do is to create a function that accepts an array of Vehicles, and a Vehicle of the exact same type as that array. Here is where the problem hides! Now look at this code:

function appendToVehicles<T extends Vehicle>(vehicles: T[], vehicle: T) {
    vehicles.push(vehicle);
}

Now we tell TS the following: “we have a function that accepts a Vehicle and an Array of Vehicles of the same type”. If we put this code in the TypeScript Playground, we will see the following:

Fixed!

Now it works exactly as intended. Because the array that we passed to the function is an array of Cars, TS inferred the generic type T to be Car , and thus the second parameter can only be a Car.

This brings us to our first piece of advice:

On the contrary to tight coupling between functions/classes, tight coupling between types is a good thing

So understanding the connection between the type of the first parameter of the function and the second helped us to come up with a better type guard. Now, let’s take a look at the next example:

class Bicycle extends Vehicle {
    numberOfWheels: number = 2;
}

function getAllByciclesByNumberOfWheels(bicycles: Bicycle[], numberOFWheels: number) {
    return bicycles.filter(bicycle => bicycle.numberOfWheels === numberOFWheels);
}

const bicycles: Bicycle[] = [new Bicycle(), new Bicycle()];

console.log(getAllByciclesByNumberOfWheels(bicycles, 2));

This is the same Bicycle as in the previous example, except that the numberOfWheels property is a number yet, not the more strict 1 | 2 | 3 | 4. Then we have a function that takes an array of Bicycles and filters by number of wheels. So, because the numberOfWheels property is a number, then the corresponding function parameter is also a number. But what if we come up and change the type of the numberOfWheels property to the more strict (and more reasonable) 1 | 2 | 3 | 4 again, as in the previous example?

class Bicycle extends Vehicle {
    numberOfWheels: 1 | 2 | 3 | 4 = 2;
}

function getAllByciclesByNumberOfWheels(bicycles: Bicycle[], numberOFWheels: number) {
    return bicycles.filter(bicycle => bicycle.numberOfWheels === numberOFWheels);
}

const bicycles: Bicycle[] = [new Bicycle(), new Bicycle()];

console.log(getAllByciclesByNumberOfWheels(bicycles, 5));

Notice how we changed the numberOfWheels property on the class, but not the corresponding function parameter. This actually resulted in an overlooked type problem: by accident I typed 5 instead of 4 when calling the function, and now it will always return an empty array, because there are no Bicycles with 5 wheels (well, at least not in our implementation). This means that we have to change every reference to this type every time we change the typing. Or does it?

In reality, we can tell the TS compiler “this parameter will always be of the same type as some property of some class”. Here is how:

function getAllByciclesByNumberOfWheels(bicycles: Bicycle[], numberOFWheels: Bicycle['numberOfWheels']) {
    return bicycles.filter(bicycle => bicycle.numberOfWheels === numberOFWheels);
}

Now this way we tell the compiler that the numberOfWheels parameter is going to be of the same type as the numberOfWheels parameter on class Bicycle. If the latter is number , it will be of type number, if it is a string, it will be a string, and so on, always the same. If we call our function again with the same wrong parameter, we will see an error:

This brings forth the second piece of advice:

We should look for single source of truth for types whenever possible, as we can overlook that some types are, in fact, entirely dependant on types of other properties or variables

The flow of types#

Sometimes the type of the parameter that enters a function will determine the output of it. Consider this function, that creates a deep copy of any object it gets as a parameter:

function deepCopy(obj: object): object {
    // some heavy lifting
}

This receives an object and returns an object, obviously. From the first example in this article, you may start feeling that something is not right here; how would TS know that the returned object has the same interface as the entering parameter (it has to, because this is a deep copy)? So mistakes like this are possible:

const car: Car = deepCopy(new Bicycle()) as Car;

TS allowed as to “safely” cast the resulting copied object to type Car even if in reality this is an instance of class Bicycle. Of course, from the same first example you may know that this can be fixed using a generic type:

function deepCopy<T extends object>(obj: T): T {
    // some heavy lifting
}

Now the wrong type cast will not be allowed by TS:

Which brings us to the third piece of advice:

We should not be very sure of the mistakes that the compiler will catch for us

Conclusion#

This is in no way a definitive guide to writing in TS; but it will help reevaluate how we perceive the type safety of our codebases. The TS compiler is very powerful, but even it can be tricked, so caution is always the advice number one.