Exploring the relationships between types
Let’s start with a straightforward illustration:
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;
}
We have a foundational class Vehicle and two derived classes, Car and Bicycle. They’re sparse in properties, making their intent clear.
Suppose we want a function that accepts either a Car or a Bicycle and appends it to an array of either type. Since both inherit from Vehicle, the natural solution is a function taking an array of Vehicles and a Vehicle as arguments:
function appendToVehicles(vehicles: Vehicle[], vehicle: Vehicle) {
vehicles.push(vehicle);
}
To this point, everything looks good. Our function is strongly typed, preventing us from pushing a number into a Vehicles array. But there’s still a hidden issue. Can you spot it? If not, examine this call to our function:
const cars: Car[] = [];
appendToVehicles(cars, new Car());
appendToVehicles(cars, new Bicycle());
console.log(cars);
We’re taking a Bicycle and attempting to push it into an array of Car instances. “That can’t possibly work!” you might be assuming.
Surprise—it works!
The compilation passes without any warnings from the TS compiler, and inspecting the console reveals this:

How on earth did that happen?
So now we’ve got an array meant for Cars that contains a Bicycle. But where did we go wrong? Is TS to blame? Should we file an issue on GitHub?
Not at all. What really occurred is that we didn’t communicate precisely what we intended. We instructed TS to build a function that accepts a Vehicles array and a Vehicle, and then adds the latter to the former. TS did exactly that—flawlessly. I can only pass a Vehicles array and a Vehicle, and that’s it. I did precisely that, since both Cars and Bicycles qualify as Vehicles. However, my actual intention was for TS to create a function that takes a Vehicles array and a Vehicle of exactly the same type as the array. That’s where the issue lurks! Now, take a look at this revised version:
function appendToVehicles<T extends Vehicle>(vehicles: T[], vehicle: T) {
vehicles.push(vehicle);
}
Here we communicate: “we possess a function that takes a Vehicle and an Array of Vehicles of the identical type.” If we run this code in the TypeScript Playground, we’ll observe:

Problem solved!
Now it behaves as anticipated. Because the array we provided is a Cars array, TS deduced the generic parameter T to be Car, thus restricting the second argument to a Car only.
This leads to our first guideline:
Unlike tight coupling between functions or classes, tight coupling between types is beneficial
Recognizing the link between the type of the function’s first argument and its second enabled us to craft a superior type guard. Now, let’s move to the next case:
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 Bicycle matches the previous one, except the numberOfWheels property remains a number rather than the stricter 1 | 2 | 3 | 4. We then have a function that takes a Bicycles array and filters based on wheel count. Since numberOfWheels is a number, the function’s parameter is also a number. But what if we decide to change the property to the more precise (and more logical) 1 | 2 | 3 | 4 again, as in the earlier 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 we modified the numberOfWheels property on the class but left the function parameter untouched. This inadvertently introduced a typing issue: I accidentally typed 5 instead of 4 when invoking the function, causing it to always return an empty array since no Bicycles have 5 wheels (at least in our implementation). Does this mean we must update every reference to this type whenever the typing changes? Not necessarily.
We can actually inform the TS compiler that “this parameter will unfailingly share the same type as a specific property of a specific class.” Here’s how:
function getAllByciclesByNumberOfWheels(bicycles: Bicycle[], numberOFWheels: Bicycle['numberOfWheels']) {
return bicycles.filter(bicycle => bicycle.numberOfWheels === numberOFWheels);
}
By doing this, we’re saying the numberOfWheels parameter will match the type of the numberOfWheels property on the Bicycle class. If that property is number, the parameter is a number; if it’s a string, the parameter becomes a string—always in sync. Re-calling our function with the same erroneous argument will now trigger an error:

This gives us our second principle:
Whenever feasible, we should seek a single source of truth for types, as it’s easy to miss that certain types are entirely reliant on the types of other properties or variables
How types flow through
Occasionally, the type of a function’s incoming parameter determines its output. Consider this function that makes a deep copy of any object it receives:
function deepCopy(obj: object): object {
// some heavy lifting
}
It takes an object and returns an object, clearly. From the first example earlier, you might sense something is off; how would TS know the returned object shares the same interface as the input parameter (which it must, being a deep copy)? Consequently, errors like this become possible:
const car: Car = deepCopy(new Bicycle()) as Car;
TS permitted us to “safely” cast the resulting copied object to Car even when it’s actually a Bicycle instance. Naturally, as with the first example, a generic type can resolve this:
function deepCopy<T extends object>(obj: T): T {
// some heavy lifting
}
Now, the incorrect type cast will be rejected by TS:

And now our third piece of advice:
We shouldn’t be overly confident about the errors the compiler will catch for us
Conclusion
This is by no means a complete guide to TS; rather, it helps us rethink how we view the type safety of our codebases. The TS compiler is potent, but it can still be outsmarted, so caution remains the primary recommendation.
