A quest to find TypeScript's inheritance tree (with top and bottom types as main NPCs)

Dariusz Cichorski - Apr 7 '22 - - Dev Community

Introduction

I need you to imagine that you’re a lvl 5 software developer aka. computer mage trying to level up fast. What’s the quickest way to do that? By doing NPC’s quests!

And today’s mission is to find TypeScript's inheritance tree. You constantly need to get some experience to develop your skills.

Image description

Object-oriented programming is a way of reflecting anything with the usage of objects with specific behaviors. As in life, those objects can share certain similarities - that's where inheritance comes in.

It's a very important aspect of programming, as it is one of the key factors that impact the code quality, especially in highly scalable systems.

TypeScript, as an example of object-oriented programming language, also supports inheritance, but it comes with some specifics worth knowing.

Join me in a quest of finding TypeScript's inheritance tree and learning about the associated top and bottom types. And trust me, such a quest can become a real journey with highs and lows on our way (literally).

Quest started - Difficulty level: MEDIUM

Finding class inheritance tree

To start, let's get straight to the point and create our first class: a Hero:

class Hero {
  private name: string;

  constructor (name: string) {
    this.name = name;
  }
}
Enter fullscreen mode Exit fullscreen mode

With that sorted out, let's go further and create two other classes which will extend the Hero class:

class Jedi extends Hero {
  constructor() {
    super('Luke Skywalker')
  }
}

class BountyHunter extends Hero {
  constructor() {
    super('Bossk')
  }
}
Enter fullscreen mode Exit fullscreen mode

The above code is a perfect example of how inheritance works in TypeScript. By extending the base class Hero, we've created two other classes: Jedi and BountyHunter, which now share all of the Hero's attributes (in our case: a name).

We can therefore say that:

A Jedi is a child class and the Hero is the parent class.

A BountyHunter is a child class and the Hero is the parent class.

If you think about it, we've just created a simple inheritance tree, which in our case looks like this:

Image description

The class inheritance tree is a reflection of how different classes extend each other and what the relationships are between them. Every type in TypeScript is part of the inheritance tree.

The tree always has multiple levels - like the one above has two (one with Hero, the second one with Jedi and BountyHunter).

Looking at the inheritance tree we can see how specific the types are on certain levels. In our case, the Hero class is the least specific type because it's above the others. Jedi and BountyHunter are more specific extensions of the Hero type.

Climbing up the inheritance tree

What if I told you there's more than that at the top of our tree? Right now the tree looks more like this:

Image description

In TypeScript every type inherits from the Object type, which means that our Hero class is the extension of that type, too. This type comes with some familiar methods that we can use, e.g. toString(), hasOwnProperty().

What's interesting in the tree is that when you look closely, you will notice the String class is another type extending the Object and it's at the same level as our Hero! There are in fact more types on this level - including all of the classes we create.

But the Object isn't our final destination. At the very top of our tree is the Unknown type - it's the least specific type in TypeScript's inheritance mechanism. You're probably wondering what's the purpose of this type, as it is in fact completely empty, without any properties and methods. Let me explain, as this is a very useful one.

Into the unknown

I'm sure you've had thousands of situations in which it was hard to specify what the exact type of the value was. It happens a lot when you work with some external APIs or libraries. I've seen the type Any being used many times in such scenarios. But that's actually a big antipattern and potential danger for our code. Unknown type requires some additional handling but it also decreases the potential for bugs thanks to the help of a compiler.

Image description

It's worth noting that a lot of the time, a programmer is responsible for using the Unknown type. It's a clear indication that the type you're dealing with should be double checked before using. And such indication comes with a lot of support from the compiler, as you'll learn in the cases below.

Type guards

The cases in which there is no guarantee what the value is, are a perfect opportunity to use the Unknown type. It makes sure that those values are not used without being strictly checked. To do so we can implement type guards:

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function isHero(maybeHero: unknown): maybeHero is Hero {
  return maybeHero instanceof Hero;
}
Enter fullscreen mode Exit fullscreen mode

Using such functions makes us secure about the type of the value. As you can see it is a very easy way of checking if the unknown value is some simple type (e.g. string) or some complex class (e.g. Hero). What's more is that once you use such a function, the compiler will know the type of checked value:

const something: unknown = 'something';

if (!isString(something)) {
  // something is of type unknown here
  throw new Error('Unsupported type.')
}

console.log(something); // something is of type string here
Enter fullscreen mode Exit fullscreen mode

Type assertions

You can use type assertions while working with Unknown type:

function func(value: unknown) {
  (value as number).toFixed(2);
}
Enter fullscreen mode Exit fullscreen mode

Such an assertion tells the compiler the type of the value, so it becomes possible to use type specific methods (e.g. toFixed() for a number). But at this point, as the NPC would say, "BEWARE! This way you are responsible for the type, not the compiler!"

Equality

In cases where you want to execute some logic when the Unknown type equals some specific value, you can do as follows:

function doSomethingAmazing(value: unknown) {
  if (value === 123) {
    amazeMe(value); // value is of type number
  }
}
Enter fullscreen mode Exit fullscreen mode

What's interesting here is that the compiler knows exactly what the type of the value is if it passes the above conditional expression. So if the value equals 123, it means the value is a number! If a string (or any other type) is passed to the above function, it will execute without any error, but the conditional result will not be positive.

Falling down from the tree

We've already reached the top of the inheritance tree, but there's some unexplored area at the bottom of it. From that perspective it looks like this:

Image description

The Never type is the bottom of the inheritence tree. It inherits from every other type in the tree. Thanks to that nothing else is assignable to it. It's more specific than any other type so it can be assigned to everything else. There are some specific cases in which this type may be helpful.

Exhaustive checking

The most common use for the Never type is to achieve exhaustive checking in conditionals:

const heroOrHunter: Jedi | BountyHunter = new Jedi();

if (heroOrHunter instanceof Jedi) {
  console.log('I am a Jedi');
} else if (heroOrHunter instanceof BountyHunter) {
  console.log('I am a BountyHunter');
} else {
  const exhaustiveCheck: never = heroOrHunter;
  throw new Error(`Unknown type ${exhaustiveCheck}`);
}
Enter fullscreen mode Exit fullscreen mode

The above code checks the type of heroOrHunter and executes some conditional logic. But in case there will be some other type in it, it will mark the type as Never, to make sure the compiler will stand strong against any other specific usage of that value. It can be very helpful and provide a higher level of code safety in more complex cases.

Error throwing function

Another common use of the Never type is as the return type of a function that exists only to throw an error:

function throwError(message: string): never {
  throw new Error(message);
}
Enter fullscreen mode Exit fullscreen mode

This way we can prevent someone from using the value returned from this function as it now returns the Never type - which is the bottom type, so it cannot be assigned to anything. This makes it a reasonable alternative for returning Void.

In TypeScript a function that does not return a value returns undefined. Returning the Never type instead of Void produces a compiler error and prevents us from such a scenario:

function sayHi(): void {
  console.log('Hi');
}

const hi: void = sayHi();
console.log(speech); // Outputs: undefined
Enter fullscreen mode Exit fullscreen mode

Conclusion and gaining EXP

We've explored TypeScript's inheritance tree and in doing so, we've covered the top and bottom types. That knowledge is one of the milestones of TypeScript's mastery.

Writing code that compiles successfully is one thing, the other is writing the code that is effective and bug-free. Using the knowledge we've learned during our quest will surely make it so.

Our quest is now complete and you, dear reader, are reaching the next level of your technical skills.

+1500 EXP!

Congrats! You are now lvl 6 software developer/computer mage.

Your next Quest awaits...

. . . . . . . . . . . .