Chapter 7. Subtyping

This chapter covers

  • Disambiguating types in TypeScript
  • Safe deserialization
  • Values for error cases
  • Type compatibility for sum types, collections, and functions

Now that we’ve covered primitive types, composition, and function types, it’s time to look at another aspect of type systems: relationships between types. In this chapter, we’ll introduce the subtyping relationship. Although you may be familiar with it from object-oriented programming, we will not cover inheritance in this chapter. Instead, we will focus on a different set of applications of subtyping.

First, we’ll talk about what subtyping is and the two ways in which programming languages implement it: structural and nominal. Then we will revisit our Mars Climate Orbiter example and explain the unique symbol trick we used in chapter 4 when discussing type safety.

Because a type can be a subtype of another type, and it can also have other subtypes, we will look at this type hierarchy: we usually have a type that sits at the top of this hierarchy and, sometimes, a type that sits at the bottom. We’ll see how we can use this top type in a scenario such as deserialization, in which we don’t have a lot of typing information readily available. We’ll also see how to use a bottom type as a value for error cases.

In the second half of the chapter, we will look at how more-complex subtyping relationships are established. This helps us understand what values we can substitute for what other values. Do we need to implement wrappers, or can we simply pass a value of another type as is? If a type is a subtype of another type, what is the subtyping relationship between collections of those two types? What about functions that take or return arguments of these types? We’ll take a simple example involving shapes and see how we can pass them around as sum types, collections, and functions, a process also known as variance. We’ll also learn about the different types of variance. But first, let’s see what subtyping means in TypeScript.

7.1. Distinguishing between similar types in TypeScript

Most of the examples in this book, even though presented in TypeScript, are language-agnostic and can be translated for most other mainstream programming languages. This section is an exception; we’ll discuss a technique specific to TypeScript. We’ll do this because it’s a great segue into a discussion of subtyping.

Let’s revisit the pound-force second/Newton-second example from chapter 4. Remember that we were modeling two different units of measurements as two different classes. We wanted to make sure that the type checker wouldn’t allow us to misinterpret a value of one type as the other, so we used unique symbol to disambiguate them. We didn’t go into the details of why we had to do this then, but let’s do it now in the following listing.

Listing 7.1. Pound-force second and Newton-second types
declare const NsType: unique symbol;      1

class Ns {
    value: number;
    [NsType]: void;                       1

    constructor(value: number) {
        this.value = value;
    }
}

declare const LbfsType: unique symbol;    2

class Lbfs {
    value: number;
    [LbfsType]: void;                     2

    constructor(value: number) {
        this.value = value;
    }
}

  • 1 We declare NsType as a unique symbol and add a property named [NsType] of type void to Ns.
  • 2 We also declare a LbfsType as a unique symbol and add a [LbfsType] property of type void to Lbfs.

If we omit these two declarations, an interesting thing happens: we can pass a Ns object as a Lbfs object, and vice versa, without getting any errors from the compiler. Let’s implement a function to demonstrate this process: a function named acceptNs() that expects a Ns argument. Then we’ll try to pass a Lbfs object to acceptNs() in the next listing.

Listing 7.2. Pound-force second and Newton-second without unique symbols
class Ns {                                        1
    value: number;

    constructor(value: number) {
        this.value = value;
    }
}

class Lbfs {                                      1
    value: number;

    constructor(value: number) {
        this.value = value;
    }
}

function acceptNs(momentum: Ns): void {           2
    console.log(`Momentum: ${momentum.value} Ns`);
}

acceptNs(new Lbfs(10));                           3

  • 1 Ns and Lbfs no longer have a unique symbol property.
  • 2 acceptNs() takes a Ns object as an argument and logs its value.
  • 3 We pass a Lbfs instance to acceptNs().

Surprisingly, this code works and logs Momentum: 10 Ns., which is definitely not what we want. The reason why we defined these two separate types was to avoid confusing the two units of measure and crashing the Mars Climate Orbiter. What’s going on? To understand what is happening, we need to understand subtyping.

Subtyping

A type S is a subtype of a type T if an instance of S can be safely used anywhere an instance of T is expected.

This is an informal definition of the famous Liskov substitution principle. Two types are in a subtype-supertype relationship if we can use an instance of the subtype whenever an instance of the supertype is expected without having to change the code.

There are two ways in which subtyping relationships are established. The first one, which most mainstream programming languages (such as Java and C#) use, is called nominal subtyping. In nominal subtyping, a type is the subtype of another type if we explicitly declare it as such, using syntax like class Triangle extends Shape. Now we can use an instance of Triangle whenever an instance of Shape is expected (such as as argument to a function). If we don’t declare Triangle as extending Shape, the compiler won’t allow us to use it as a Shape.

On the other hand, structural subtyping doesn’t require us to state the subtyping relationship explicitly in code. An instance of a type, such as Lbfs, can be used instead of another type, such as Ns, as long as it has all the members that the other type declares. In other words, if a type has a similar structure to another type (the same members and optionally additional members), it is automatically considered to be a subtype of that other type.

Nominal and Structural Subtyping

In nominal subtyping, a type is a subtype of another type if we explicitly declare it as such. In structural subtyping, a type is a subtype of another type if it has all the members of the supertype and, optionally, additional members.

Unlike C# and Java, TypeScript uses structural subtyping. That’s the reason why, if we declare Ns and Lbfs as classes with only a value member of type number, they can still be used interchangeably.

7.1.1. Structural and nominal subtyping pros and cons

In many cases, structural subtyping is useful, as it allows us to establish relationships between types even if they are not under our control. Suppose that a library we use defines a User type as having a name and age. In our code, we have a Named interface that requires a name property on implementing types. We can use an instance of User whenever a Named is expected, even though User does not explicitly implement Named, as shown in the next listing. (We don’t have the declaration class User implements Named.)

Listing 7.3. User is structurally a subtype of Named
/* Library code */
class User {                             1
    name: string;
    age: number;

    constructor(name: string, age: number) {
        this.name = name;
        this.age = age;
    }
}

/* Our code */
interface Named {
    name: string;
}

function greet(named: Named): void {     2
    console.log(`Hi ${named.name}!`);
}

greet(new User("Alice", 25));            3

  • 1 User is a type from an external library that we can’t modify.
  • 2 greet() expects an instance conforming to the Named interface.
  • 3 We can pass a User instance as a Named.

If we had to explicitly declare that User implements Named, we would be in trouble, because User is a type that comes from an external library. We can’t change library code, so we would have to work around this situation by declaring a new type that extends User and implements Named (class NamedUser extends User implements Named {}) just to connect the two types. We don’t need to do this if our type system uses structural subtyping.

On the other hand, in some situations we absolutely don’t want a type to be considered a subtype of another type based simply on its structure. A Lbfs instance should never be used instead of a Ns instance, for example. In nominal subtyping, this is the default, which makes it very easy to avoid mistakes. On the other hand, structural subtyping requires us to do more work to ensure that a value is of the type we expect it to be rather than a value of a type with a similar shape. In such scenarios, structural subtyping is much better.

If we want to use nominal subtyping, we can use several techniques to enforce it in TypeScript. One of them is the unique symbol trick we’ve used throughout the book. Let’s zoom in on it.

7.1.2. Simulating nominal subtyping in TypeScript

In our Ns/Lbfs case, we are effectively trying to simulate nominal subtyping. We want to make sure that the compiler considers a type to be a subtype of Ns only if we explicitly declare it as such, not just because it has a value member.

To achieve this, we need to add a member to Ns that no other type can declare accidentally. In TypeScript, unique symbol generates a “name” that’s guaranteed to be unique across all the code. Different unique symbol declarations will generate different names, and no user-declared name can ever match a generated name.

We declare a unique symbol to represent our Ns type as NsType. The unique symbol declaration looks like this: declare const NsType: unique symbol (as in listing 7.1). Now that we have a unique name, we can create a property with that name by putting the name in square brackets. We need to define a type for this property, but we aren’t really going to assign anything to it because we’re just using it to disambiguate types. Because we don’t care about its actual value, a unit type is best suited for this purpose, so we use void.

We do the same for Lbfs, and now the types have different structures: one of them has a [NsType] property, and the other has a [LbfsType] property, as shown in listing 7.4. Because we used unique symbol, it’s impossible to accidentally define a property with the same name on another type. The only way to come up with a subtype for Ns and Lbfs now is to explicitly inherit from them.

Listing 7.4. Simulating nominal subtyping
declare const NsType: unique symbol;

class Ns {
    value: number;
    [NsType]: void;

    constructor(value: number) {
        this.value = value;
    }
}

declare const LbfsType: unique symbol;

class Lbfs {
    value: number;
    [LbfsType]: void;

    constructor(value: number) {
        this.value = value;
    }
}

function acceptNs(momentum: Ns): void {
    console.log(`Momentum: ${momentum.value} Ns`);
}

acceptNs(new Lbfs(10));        1

  • 1 This no longer compiles.

When we try to pass a Lbfs instance as a Ns, we get the following error:

Argument of type 'Lbfs' is not assignable to parameter of
type 'Ns'. Property '[NsType]' is missing in type 'Lbfs'
but required in type 'Ns'.

In this section, we saw a definition of subtyping and learned about the two ways in which the subtyping relationship between two types can be established: nominally (because we say so) and structurally (because the types have the same structure). We also saw how, even though TypeScript uses structural subtyping, we can simulate nominal subtyping by using unique symbols for the situations in which structural subtyping is not appropriate.

7.1.3. Exercises

1

In TypeScript, is Painting a subtype of Wine for the types defined as

class Wine {
    name: string;
    year: number;
}

class Painting {
    name: string;
    year: number;
    painter: Painter;
}

2

In TypeScript, is Car a subtype of Wine for the types defined as

class Wine {
    name: string;
    year: number;
}

class Car {
    make: string;
    model: string;
    year: number;
}

7.2. Assigning anything to, assigning to anything

Now that we’ve learned about subtyping, let’s look at a couple of extremes: a type to which we can assign anything and a type that we can assign to anything. The first one is a type we can use to store absolutely anything. The second is a type we can use instead of any other type if we don’t have an instance of that other type handy.

7.2.1. Safe deserialization

We covered the unknown and any types in chapter 4. unknown is a type that can store a value of any other type. We mentioned that other object-oriented languages usually provide a type named Object with similar behavior. In fact, TypeScript has an Object type too; it provides a few common methods such as toString(). But the story doesn’t end there, as we’ll see in this section.

The any type is more dangerous. We can not only assign any value to it, but also assign an any value to any other type, bypassing type checking. This type is used for interoperability with JavaScript code but may have unintended consequences. Suppose that we have a function that deserializes an object using the standard JSON.parse(), as shown in the next listing. Because JSON.parse() is a JavaScript function with which TypeScript interoperates, it is not strongly typed; its return type is any. Assume that we are expecting to deserialize a User instance that has a name property.

Listing 7.5. Deserializing any
class User {
    name: string;                              1

    constructor(name: string) {
        this.name = name;
    }
}

function deserialize(input: string): any {
    return JSON.parse(input);                  2
}

function greet(user: User): void {
    console.log(`Hi ${user.name}!`);           3
}

greet(deserialize('{ "name": "Alice" }'));     4
greet(deserialize('{}'));                      5

  • 1 The User type has a name property.
  • 2 deserialize() simply wraps JSON.parse() and returns a value of type any.
  • 3 greet() uses the name property of the given User object.
  • 4 We deserialize a valid User JSON.
  • 5 We can also deserialize an object that is not a User object.

The last call to greet() will log "Hi undefined!" because any bypasses type checking, and the compiler allows us to treat the returned value as a value of type User, even when we didn’t get a value of that type. This result is clearly not ideal. We need to check that we have the right type before we call greet().

In this case, we’d want to ensure that the object we have has a name property of type string, which in our case is enough to cast it into a User. We should also check that our object is not null or undefined, which are special types in TypeScript. One way of doing this is to update our code with such a check and call it before calling greet(). Note that this type check is done at run time, because it depends on the input value and is not something that can be enforced statically.

Listing 7.6. Run-time type checking for User
class User {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

function deserialize(input: string): any {
    return JSON.parse(input);
}

function greet(user: User): void {
    console.log(`Hi ${user.name}!`);
}

function isUser(user: any): user is User {           1
    if (user === null || user === undefined)
        return false;

    return typeof user.name === 'string';
}

let user: any = deserialize('{ "name": "Alice" }');
if (isUser(user))                                    2
    greet(user);

user = undefined;
if (isUser(user))                                    2
    greet(user);

  • 1 This function checks whether the given argument is of type User. We consider a variable with a name property of type string to be of User type.
  • 2 Checks that user has a property name of type string before each use.

The user is User return type of isUser() is a bit of TypeScript-specific syntax, but I hope that it’s not too confusing. This type is very much like a boolean return type, but it carries extra meaning for the compiler. If the function returns true, the variable user has type User, and the compiler can use that information in the caller. Effectively, within each if block in which isUser() returned true, user has type User instead of any.

This approach works. Running the code executes only the first call when our username is Alice. The second call to greet() will not be executed because in this case, there is no name property on user. There’s still a problem with this approach, though: we are not forced to implement this check. Because no enforcement is going on, we could make a mistake and forget to call it, which would allow an arbitrary result from deserialize() to make its way to greet(), and there’s nothing to stop it from doing so.

Wouldn’t it be great if we had another way of saying, “This object can be of absolutely any type” but without the additional “Trust me, I know what I’m doing” that any implies? We need another type—a type that is a supertype of any other type in the system, which means that regardless of what JSON.parse()returns, it will be a subtype of this type. From there on, the type system will ensure that we add the proper type checking before we cast it to User.

Top type

A type to which we can assign any value is also called a top type because any other type is a subtype of this type. In other words, this type sits at the top of the subtyping hierarchy (figure 7.1).

Figure 7.1. A top type is the supertype of any other type. We can define any number of types, but any of them would be a subtype of the top type. We can use a value of any type wherever the top type is expected.

Let’s update our implementation. We can start with the Object type, which is the supertype of most types in the type systems, with two exceptions: null and undefined. The TypeScript type system has some great safety features, one of them being the ability to keep null and undefined values outside the domain of other types. Remember the billion-dollar-mistake sidebar in chapter 3—the fact that in most languages, we can assign null to any type. This is not allowed in TypeScript if we use the --strictNullChecks compiler flag (which is strongly recommended). TypeScript considers null to be of type null and undefined to be of type undefined. So our top type, the supertype of absolutely anything, is the sum of these three types: Object | null | undefined. This type is actually defined out of the box as unknown. Let’s rewrite our code to use unknown, as shown in the next listing, and then we can discuss the differences between using any and unknown.

Listing 7.7. Stronger typing using unknown
class User {
    name: string;

    constructor(name: string) {
        this.name = name;
    }
}

function deserialize(input: string): unknown {            1
    return JSON.parse(input);
}

function greet(user: User): void {
    console.log(`Hi ${user.name}!`);
}

function isUser(user: any): user is User {                2
    if (user === null || user === undefined)
        return false;

    return typeof user.name === 'string';
}

let user: unknown = deserialize('{ "name": "Alice" }');   3
if (isUser(user))
    greet(user);

user = deserialize("null");
if (isUser(user))
    greet(user);

  • 1 We make deserialize() return unknown.
  • 2 We keep the isUser() argument as any.
  • 3 We declare our variable as having type unknown.

The change is subtle but powerful: as soon as we get a value from JSON.parse(), we convert it from any to unknown. This process is safe, because anything can be converted to unknown. We keep the argument of isUser() as any, because it makes our implementation easier. (We wouldn’t be allowed to perform a check such as typeof user.name on an unknown without extra casting.)

The code works as before, the distinction being that if we remove any of the isUser() calls, the code no longer compiles. The compiler issues the following error:

Argument of type 'unknown' is not assignable to parameter
of type 'User'.

We can’t simply pass a variable of type unknown to greet(), which expects a User. The function isUser() helps, as whenever it returns true, the compiler automatically considers the variable to have type User.

With this implementation, we simply cannot forget to check; the compiler will not allow us. It allows us to use our object as a User only after we confirm that user is User.

Difference between unknown and any

Although we can assign anything to both unknown and any, there is a difference in how we use a variable of one of these types. In the unknown case, we can use the value as some type (such as User) only after we confirm that the value actually has that type (as we did with the function that returns the user as User). In the any case, we can use the value as a value of any other type right away. any bypasses type checking.

Other languages provide different mechanisms to determine whether a value is of a given type. C# has the is keyword, for example, and Java has instanceof. In general, when we deal with a value that could be anything, we start by considering it to be a top type. Then we use the appropriate checks to ensure that it is of the type we need before we downcast it to the required type.

7.2.2. Values for error cases

Now let’s look at an opposite problem: a type that can be used instead of any other type. Let’s take a simple example in listing 7.8. In our game, we can turn our spaceship Left or Right. We’ll represent these possible directions as an enumeration. We want to implement a function that takes a direction and converts it to an angle by which we rotate our spaceship. Because we want to make sure that we cover all cases, we’ll throw an error if the enumeration has a value different from the two expected Left and Right values.

Listing 7.8. TurnDirection to angle conversion
enum TurnDirection {
    Left,
    Right
}

function turnAngle(turn: TurnDirection): number {
    switch (turn) {
        case TurnDirection.Left: return -90;                  1
        case TurnDirection.Right: return 90;                  1
        default: throw new Error("Unknown TurnDirection");    2
    }
}

  • 1 A Left turn becomes –90 degrees; a Right turn becomes 90 degrees.
  • 2 We throw an error in case we encounter an unexpected value.

So far, so good. But what if we have a function that handles error scenarios? Suppose that we want to log the error before throwing it. This function would always throw, so we’ll declare it as returning the type never, as we saw in chapter 2. As a reminder, never is the empty type that cannot be assigned any value. We use it to explicitly show that a function never returns, either because it loops forever or because it throws, as shown in the next listing.

Listing 7.9. Error reporting
function fail(message: string): never {      1
    console.error(message);                  2
    throw new Error(message);                2
}

  • 1 fail() never returns (always throws), so we declare it as returning never.
  • 2 Print error to console and then throw.

If we want to replace the throw statement in turnAngle() with fail(), we end up with something like the following.

Listing 7.10. turnAngle() using fail()
function turnAngle(turn: TurnDirection): number {
    switch (turn) {
        case TurnDirection.Left: return -90;
        case TurnDirection.Right: return 90;
        default: fail("Unknown TurnDirection");       1
    }
}

  • 1 We replace throw with a call to fail().

This code almost works, but not quite. Compilation fails in strict mode (with --strict flag) with the following error:

Function lacks ending return statement and return type
does not include "undefined".

The compiler doesn’t see a return statement on the default branch and flags that as an error. One fix would be to return a dummy value as shown in the next listing, knowing that we throw before reaching it anyway.

Listing 7.11. turnAgain() using fail() and returning a dummy value
enum TurnDirection {
    Left,
    Right
}

function turnAngle(turn: TurnDirection): number {
    switch (turn) {
        case TurnDirection.Left: return -90;
        case TurnDirection.Right: return 90;
        default: {
            fail("Unknown TurnDirection");
            return -1;                       1
        }
    }
}

  • 1 Dummy value that will never actually be returned because fail() throws

But what if, at some point in the future, we update fail()in such a way that it doesn’t always throw? Then our code would end up returning a dummy value, even though it should never do so. There’s a better solution: return the result of fail(), as the following listing shows.

Listing 7.12. turnAngle() using fail() and returning its result
function turnAngle(turn: TurnDirection): number {
    switch (turn) {
        case TurnDirection.Left: return -90;
        case TurnDirection.Right: return 90;
        default: return fail("Unknown TurnDirection");      1
    }
}

  • 1 Just return whatever fail() returns.

The reason why this code works is that besides being the type without values, never is the type that is the subtype of all other types in the system.

Bottom type

A type that is the subtype of any other type is called a bottom type because it sits at the bottom of the subtyping hierarchy. To be a subtype of any other possible type, it must have the members of any other possible type. Because we can have an infinite number of types and members, the bottom type would also have to have an infinite number of members. Because that is impossible, the bottom type is always an empty type: a type for which we can’t create an actual value (figure 7.2).

Figure 7.2. A bottom type is the subtype of any other type. We can define any number of types, but any of these would be a supertype of the bottom type. We can pass a value of the bottom type wherever a value of any type is required (although we can never produce such a value).

Because we can assign never to any other type, due to it being a bottom type, we can return it from the function. The compiler will not complain, as this is an upcast (converting a value from a subtype to a supertype), which can be done implicitly. We’re saying, “Take this value that is impossible to create and turn it into a string,” which is fine. Because the fail() function never returns, we never end up in a situation in which we actually have something to turn into a string.

This approach is better than the preceding one because, if we update fail() so that it doesn’t throw in some cases, the compiler will force us to fix all our code. First, it will force us to change the return type of fail() from never to something else, such as void. Then it will see that we are trying to pass that as a string, which does not type-check. We will have to update our implementation of turnAngle(), perhaps by bringing back the explicit throw.

A bottom type allows us to pretend that we have a value of any type even if we can’t come up with one.

7.2.3. Top and bottom types recap

Let’s quickly recap what we covered in this section. Two types can be in a subtyping relationship, in which one of them is the supertype and the other is the subtype. At the extreme, we have a type that is the supertype of any other type and a type that is the subtype of any other type.

The supertype of any other type, called the top type, can be used to hold a value of any other type. That type is unknown in TypeScript. One situation in which this comes in handy is when we are dealing with data that can be anything, such as as a JSON document read from a NoSQL database. We initially type such data as the top type and then perform the required checks to cast it down to a type we can work with.

The subtype of any other type, called the bottom type, can be used to produce a value of any other type. This type is never in TypeScript. One example application is producing a return value when none can be produced via a function that always throws.

Note that although most mainstream languages provide a top type, few of them provide a bottom type. The DIY implementation we saw in chapter 2 makes a type empty but not bottom. Unless worked into the compiler, there is no way to define our custom bottom type.

Next, let’s look at subtyping for more complex types and see how that works.

7.2.4. Exercises

1

If we have a function makeNothing() that returns never, can we initialize a variable x of type number with its result (without casting)?

declare function makeNothing(): never;

let x: number = makeNothing();

2

If we have a function makeSomething() that returns unknown, can we initialize a variable x of type number with its result (without casting)?

declare function makeSomething(): unknown;

let x: number = makeSomething();

7.3. Allowed substitutions

So far, we’ve looked at a few simple examples of subtyping. We observed, for example, that if Triangle extends Shape, Triangle is a subtype of Shape. Now let’s try to answer a few trickier questions:

  • What is the subtyping relationship between the sum types Triangle | Square and Triangle | Square | Circle?
  • What is the subtyping relationship between an array of triangles (Triangle[]) and an array of shapes (Shape[])?
  • What is the subtyping relationship between a generic data structure such as List<T>, for List<Triangle> and List<Shape>?
  • What about the function types () => Shape and () => Triangle?
  • Conversely, what about the function type (argument: Shape) => void and the function type (argument: Triangle) => void?

These questions are important, as they tell us which of these types can be substituted for their subtypes. Whenever we see a function that expects an argument of one of these types, we should understand whether we can provide a subtype instead.

The challenge in the preceding examples is that things aren’t as straightforward as Triangle extends Shape. We are looking at types that are defined based on Triangle and Shape. Triangle and Shape are part of the sum types, the types of elements of a collection, or a function’s argument types or return types.

7.3.1. Subtyping and sum types

Let’s take the simplest example first: the sum type. Suppose that we have a draw() function that can draw a Triangle, a Square, or a Circle. Can we pass a Triangle or Square to it? As you might have guessed, the answer is yes. We can check that such code compiles in the following listing.

Listing 7.13. Triangle | Square as Triangle | Square | Circle
declare const TriangleType: unique symbol;
class Triangle {
    [TriangleType]: void;
    /* Triangle members */
}

declare const SquareType: unique symbol;
class Square {
    [SquareType]: void;
    /* Square members */
}

declare const CircleType: unique symbol;
class Circle {
    [CircleType]: void;
    /* Circle members */
}

declare function makeShape(): Triangle | Square;                  1
declare function draw(shape: Triangle | Square | Circle): void;   2

draw(makeShape());

  • 1 makeShape() returns a Triangle or a Square (implementation omitted).
  • 2 draw() accepts a Triangle, a Square, or a Circle (implementation omitted).

We enforce nominal subtyping throughout these examples because we’re not providing full implementations for these types. In practice, they would have various different properties and methods to distinguish them. We simulate these different properties with unique symbols for our examples, as leaving the classes empty would make all of them equivalent due to TypeScript’s structural subtyping.

As expected, this code compiles. The opposite doesn’t: if we can draw a Triangle or a Square and attempt to draw a Triangle, Square, or Circle, the compiler will complain, because we might end up passing a Circle to the draw() function, which wouldn’t know what to do with it. We can confirm that the following code doesn’t compile.

Listing 7.14. Triangle | Square | Circle as Triangle | Square
declare function makeShape(): Triangle | Square | Circle;    1
declare function draw(shape: Triangle | Square): void;       1

draw(makeShape());                                           2

  • 1 We flipped the types so that makeShape() could also return a Circle, whereas draw() no longer accepts a Circle.
  • 2 This no longer compiles.

Triangle | Square is a subtype of Triangle | Square | Circle: we can always substitute a Triangle or Square for a Triangle, Square, or Circle but not the other way around.

This situation may seem to be counterintuitive, because Triangle | Square is “less” than Triangle | Square | Circle. Whenever we use inheritance, we end up with a subtype that has more properties than its supertype. For sum types, it works the opposite way: the supertype has more types than the subtype (figure 7.3).

Figure 7.3. Triangle | Square is a subtype of Triangle | Square | Circle because whenever a Triangle, Square, or Circle is expected, we can use a Triangle or a Square.

Say we have an EquilateralTriangle which inherits from Triangle, as shown in the next listing.

Listing 7.15. EquilateralTriangle declaration
declare const EquilateralTriangleType: unique symbol;
class EquilateralTriangle extends Triangle {
    [EquilateralTriangleType]: void;
    /* EquilateralTriangle members */
}

As an exercise, check what happens when we mix sum types with inheritance. Does makeShape() returning EquilateralTriangle | Square and draw() accepting Triangle | Square | Circle work? What about makeShape() returning Triangle | Square and draw() accepting EquilateralTriangle | Square | Circle?

This form of subtyping is something that has to be supported by the compiler. With a DIY sum type like the Variant we looked at in chapter 3, we would not get the same subtyping behavior. Remember the Variant can wrap a value of one of several types, but it is not itself any of those types.

7.3.2. Subtyping and collections

Now let’s look at types that contain a set of values of some other type. Let’s start with arrays in the next listing. Can we pass an array of Triangle objects to a draw() function that accepts an array of Shape objects if Triangle is a subtype of Shape?

Listing 7.16. Triangle[] as Shape[]
class Shape {
    /* Shape members */
}

declare const TriangleType: unique symbol;
class Triangle extends Shape {                   1
    [TriangleType]: void;
    /* Triangle members */
}

declare function makeTriangles(): Triangle[];    2
declare function draw(shapes: Shape[]): void;    3

draw(makeTriangles());                           4

  • 1 Triangle is a subtype of Shape.
  • 2 makeTriangles() returns an array of Triangle objects.
  • 3 draw() accepts an array of Shape objects.
  • 4 We can use an array of Triangle objects as an array of Shape objects.

This observation may not be surprising, but it is important: arrays preserve the subtyping relationship of the underlying types that they are storing. As expected, the opposite doesn’t work: if we try to pass an array of Shape objects when an array of Triangle objects is expected, the code won’t compile (figure 7.4).

Figure 7.4. If Triangle is a subtype of Shape, an array of triangles is a subtype of an array of shapes. If we can use a Triangle as a Shape, we can use an array of Triangle objects as an array of Shape objects.

As we saw in chapter 2, arrays are basic types that come out of the box in many programming languages. What if we define a custom collection, such as a LinkedList<T>?

Listing 7.17. LinkedList<Triangle> as LinkedList<Shape>
class LinkedList<T> {                                      1
    value: T;
    next: LinkedList<T> | undefined = undefined;

    constructor(value: T) {
        this.value = value;
    }

    append(value: T): LinkedList<T> {
        this.next = new LinkedList(value);
        return this.next;
    }
}

declare function makeTriangles(): LinkedList<Triangle>;    2
declare function draw(shapes: LinkedList<Shape>): void;    3

draw(makeTriangles());                                     4

  • 1 A generic linked list collection
  • 2 makeTriangle() now returns a linked list of triangles.
  • 3 draw() accepts a linked list of shapes.
  • 4 The code compiles.

Even without a primitive type, TypeScript correctly establishes that LinkedList-<Triangle> is a subtype of LinkedList<Shape>. As before, the opposite doesn’t compile; we can’t pass a LinkedList<Shape> as a LinkedList<Triangle>.

Covariance

A type that preserves the subtyping relationship of its underlying type is called covariant. An array is covariant because it preserves the subtyping relationship: Triangle is a subtype of Shape, so Triangle[] is a subtype of Shape[].

Various languages behave differently when dealing with arrays and collections such as LinkedList<T>. In C#, for example, we would have to explicitly state covariance for a type such as LinkedList<T> by declaring an interface and using the out keyword (ILinkedList<out T>). Otherwise, the compiler will not deduce the subtyping relationship.

An alternative to covariance is to simply ignore the subtyping relationship between two given types and consider a LinkedList<Shape> and LinkedList<Triangle> to be types with no subtyping relationship between them. (Neither is a subtype of the other.) This is not the case in TypeScript, but it is in C#, in which a List<Shape> and a List<Triangle> have no subtyping relationship.

INVARIANCE

A type that ignores the subtyping relationship of its underlying type is called invariant. A C# List<T> is invariant because it ignores the subtyping relationship "Triangle is a subtype of Shape", so List<Shape> and List<Triangle> have no subtype–supertype relationship.

Now that we’ve looked at how collections relate to one another in terms of subtyping and have seen two common types of variance, let’s see how function types are related.

7.3.3. Subtyping and function return types

We’ll start with the simpler case first: let’s see what substitutions we can make between a function that returns a Triangle and a function that returns a Shape, as shown in listing 7.18. We’ll declare two factory functions: a makeShape() that returns a Shape and a makeTriangle() that returns a Triangle.

Then we’ll implement a useFactory() function that takes a function of type () => Shape as argument and returns a Shape. We’ll try passing makeTriangle() to it.

Listing 7.18. () => Triangle as () => Shape
declare function makeTriangle(): Triangle;
declare function makeShape(): Shape;

function useFactory(factory: () => Shape): Shape {     1
    return factory();                                  1
}

let shape1: Shape = useFactory(makeShape);             2
let shape2: Shape = useFactory(makeTriangle);          2

  • 1 useFactory() takes a function with no arguments that returns a Shape and calls it.
  • 2 Both makeTriangle() and makeShape() can be used as arguments to useFactory().

Nothing is out of the ordinary here: we can pass a function that returns a Triangle as a function that returns a Shape because the return value (a Triangle) is a subtype of Shape, so we can assign it to a Shape (figure 7.5).

Figure 7.5. If Triangle is a subtype of Shape, we can use a function that returns a Triangle instead of a function that returns a Shape because we can always assign a Triangle to a caller that expects a Shape.

The opposite doesn’t work: if we change our useFactory() to expect a () => Triangle argument and try to pass it makeShape(), the following code won’t compile.

Listing 7.19. () => Shape as () => Triangle
declare function makeTriangle(): Triangle;
declare function makeShape(): Shape;

function useFactory(factory: () => Triangle): Triangle {    1
    return factory();
}

let shape1: Shape = useFactory(makeShape);                  2
let shape2: Shape = useFactory(makeTriangle);

  • 1 We replace Shape with Triangle here.
  • 2 Code fails to compile; we can’t use makeShape() as a () => Triangle.

Again, this code is pretty straightforward: we can’t use makeShape() as a function of type () => Triangle because makeShape() returns a Shape object. That object could be a Triangle, but it also might be a Square. useFactory() promises to return a Triangle, so it can’t return a supertype of Triangle. It could, of course, return a subtype such as EquilateralTriangle, given a makeEquilateralTriangle().

Functions are covariant in their return types. In other words, if Triangle is a subtype of Shape, a function type such as () => Triangle is a subtype of a function () => Shape. Note that the function types don’t have to describe functions that don’t take any arguments. If both makeTriangle() and makeShape() took a couple of number arguments, they would still be covariant, as we just saw.

This behavior is followed by most mainstream programming languages. The same rules are followed for overriding methods in inherited types, changing their return type. If we implement a ShapeMaker class that provides a make() method that returns a Shape, we can override it in a derived class MakeTriangle to return Triangle instead, as shown in the following listing. The compiler allows this, as calling either of the make() methods will give us a Shape object.

Listing 7.20. Overriding a method with a subtype as return type
class ShapeMaker {
    make(): Shape {                        1
        return new Shape();
    }
}

class TriangleMaker extends ShapeMaker {   2
    make(): Triangle {                     3
        return new Triangle();
    }
}

  • 1 ShapeMaker defines a method make(), which returns a Shape object.
  • 2 TriangleMaker inherits from ShapeMaker.
  • 3 TriangleMaker overrides make() and changes its return type to Triangle.

Again, this behavior is allowed in most mainstream programming languages, as most consider functions to be covariant in their return type. Let’s see what happens to function types whose argument types are subtypes of one another.

7.3.4. Subtyping and function argument types

We’ll turn things inside out here, so instead of using a function that returns a Shape and a function that returns a Triangle, we’ll take a function that takes a Shape as argument and a function that takes a Triangle as argument. We’ll call these functions drawShape() and drawTriangle(). How do (argument: Shape) => void and (argument: Triangle) => void relate to each other?

Let’s introduce another function, render(), that takes as arguments a Triangle and an (argument: Triangle) => void function, as the next listing shows. It simply calls the given function with the given Triangle.

Listing 7.21. Draw and render functions
declare function drawShape(shape: Shape): void;            1
declare function drawTriangle(triangle: Triangle): void;   1

function render(
    triangle: Triangle,                                    2
    drawFunc: (argument: Triangle) => void): void {        2
    drawFunc(triangle);                                    3
}

  • 1 drawShape() takes a Shape argument; drawTriangle() takes a Triangle argument.
  • 2 render() expects a Triangle and a function that takes a Triangle as argument.
  • 3 render() simply calls the provided function, passing it the triangle it received.

Here comes the interesting bit: in this case, we can safely pass drawShape() to the render() function! We can use a (argument: Shape) => void where an (argument: Triangle) => void is expected.

Logically, it makes sense: we have a Triangle, and we pass it to a drawing function that can use it as an argument. If the function itself expects a Triangle, like our drawTriangle() function, it of course works. But it should also work for a function that expects a supertype of Triangle. drawShape() wants a shape—any shape—to draw. Because it doesn’t use anything that’s triangle-specific, it is more general than drawTriangle(); it can accept any shape as argument, be it Triangle or Square. So in this particular case, the subtyping relationship is reversed.

Contravariance

A type that reverses the subtyping relationship of its underlying type is called contravariant. In most programming languages, functions are contravariant with regard to their arguments. A function that expects a Triangle as argument can be substituted for a function that expects a Shape as argument. The relationship of the functions is the reverse of the relationship of the argument types. If Triangle is a subtype of Shape, the type of function that takes a Triangle as an argument is a supertype of the type of function that takes a Shape as an argument (figure 7.6).

Figure 7.6. If Triangle is a subtype of Shape, we can use a function that expects a Shape as argument instead of a function that expects a Triangle as argument because we can always pass a Triangle to a function that takes a Shape.

We said “most programming languages” earlier. A notable exception is TypeScript. In TypeScript, we can also do the opposite: pass a function that expects a subtype instead of a function that expects a supertype. This choice was an explicit design choice made to facilitate common JavaScript programming patterns. It can lead to run-time issues, though.

Let’s look at an example in the next listing. First, we’ll define a method isRightAngled() on our Triangle type, which would determine whether a given instance describes a right-angled triangle. The implementation of the method is not important.

Listing 7.22. Shape and Triangle with isRightAngled() method
class Shape {
    /* Shape members */
}

declare const TriangleType: unique symbol;
class Triangle extends Shape {
    [TriangleType]: void;

    isRightAngled(): boolean {              1
        let result: boolean = false;

        /* Determine whether it is a right-angled triangle */

        return result;
    }

    /* More Triangle members */
}

  • 1 The isRightAngled() method tells us whether an instance describes a right-angled triangle.

Now let’s reverse the drawing example, as shown in listing 7.23. Suppose that our render() function expects a Shape instead of a Triangle and a function that can draw shapes (argument: Shape) => void instead of a function that can draw only triangles (argument: Triangle) => void.

Listing 7.23. Updated draw and render functions
declare function drawShape(shape: Shape): void;             1
declare function drawTriangle(triangle: Triangle): void;    1

function render(
    shape: Shape,                                           2
    drawFunc: (argument: Shape) => void): void {            2
    drawFunc(shape);                                        3
}

  • 1 drawShape() and drawTriangle() are just like before.
  • 2 render() expects a Shape and a function that takes a Shape as argument.
  • 3 render() simply calls the provided function passing it the shape it received.

Here’s how we can cause a run-time error: we can define drawTriangle() to use something that is triangle-specific, such as the isRightAngled() method we just added. Then we call render with a Shape object (not a Triangle) and drawTriangle().

Now drawTriangle() will receive a Shape object and attempt to call isRight-Angled() on it in the next listing, but because the Shape is not a Triangle, this will cause an error.

Listing 7.24. Attempting to call isRightAngled() on a supertype of Triangle
function drawTriangle(triangle: Triangle): void {
    console.log(triangle.isRightAngled());          1
    /* ... */
}

function render(
    shape: Shape,
    drawFunc: (argument: Shape) => void): void {
    drawFunc(shape);
}

render(new Shape(), drawTriangle);                  2

  • 1 drawTriangle() calls a Triangle-specific method on the given argument.
  • 2 We can pass a Shape and drawTriangle() to render.

This code will compile, but it will fail at run time with a JavaScript error, because the run time won’t be able to find isRightAngled() on the Shape object we gave to drawTriangle(). This result is not ideal, but as mentioned before, it was a conscious decision made during the implementation of TypeScript.

In TypeScript, if Triangle is a subtype of Shape, a function of type (argument: Shape) => void and a function of type (argument: Triangle) => void can be substituted for each other. Effectively, they are subtypes of each other. This property is called bivariance.

Bivariance

Types are bivariant if, from the subtyping relationship of their underlying types, they become subtypes of each other. In TypeScript, if Triangle is a subtype of Shape, the function types (argument: Shape) => void and (argument: Triangle) => void are subtypes of each other (figure 7.7).

Figure 7.7. If Triangle is a subtype of Shape, in TypeScript, a function that expects a Triangle can be used instead of a function that expects a Shape, and a function that expects a Shape can be used instead of a function that expects a Triangle.

Again, the bivariance of functions with respect to their arguments in TypeScript allows incorrect code to compile. A major theme of this book is relying on the type system to eliminate run-time errors at compile time. In TypeScript, it was a deliberate design decision to enable common JavaScript programming patterns.

7.3.5. Variance recap

Throughout this section, we’ve looked at what types can be substituted for what other types. Although subtyping is straightforward for dealing with simple inheritance, things get more complicated when we add types parameterized on other types. These types could be collections, function types, or other generic types. The way that the subtyping relationships of these parameterized types is removed, preserved, reversed, or made two-way based on the relationship of their underlying types is called variance:

  • Invariant types ignore the subtyping relationship of their underlying types.
  • Covariant types preserve the subtyping relationship of their underlying types. If Triangle is a subtype of Shape, an array of type Triangle[] is a subtype of an array of type Shape[]. In most programming languages, function types are covariant in their return types.
  • Contravariant types reverse the subtyping relationship of their underlying types. If Triangle is a subtype of Shape, the function type (argument: Shape) => void is a subtype of the function type (argument: Triangle) => void in most languages. This is not true for TypeScript, in which function types are bivariant with regard to their argument types.
  • Bivariant types are subtypes of each other when their underlying types are in a subtyping relationship. If Triangle is a subtype of Shape, the function type (argument: Shape) => void and the function type (argument: Triangle) => void are subtypes of each other. (Functions of both types can be substituted for each other.)

Although some common rules exist across programming languages, there is no one way to support variance. You should understand what the type system of your programming language does and how it establishes subtyping relationships. This is important to know, as these rules tell us what can be substituted for what. Do you need to implement a function to transform a List<Triangle> into a List<Shape>, or can you just use the List<Triangle> as is? The answer depends on the variance of List<T> in your programming language of choice.

7.3.6. Exercises

In the following exercises, Triangle is a subtype of Shape. We are going to use the variance rules of TypeScript.

1

Can we pass a Triangle variable to a function drawShape(shape: Shape): void?

2

Can we pass a Shape variable to a function drawTriangle(triangle: Triangle): void?

3

Can we pass an array of Triangle objects (Triangle[]) to a function drawShapes(shapes: Shape[]): void?

4

Can we assign the drawShape() function to a variable of function type (triangle: Triangle) => void?

5

Can we assign the drawTriangle() function to a variable of function type (shape: Shape) => void?

6

Can we assign a function getShape(): Shape to a variable of function type () => Triangle?

Summary

  • We defined subtyping and the two ways that programming languages determine whether a type is a subtype of another type: structural or nominal.
  • We looked at a TypeScript technique to simulate nominal subtyping in a language with structural subtyping.
  • We saw an application for the top type, the type that sits at the top of the subtyping hierarchy: safe deserialization.
  • We also saw an application for the bottom type, the type that sits at the bottom of the subtyping hierarchy: as a value type for error scenarios.
  • We covered subtyping between sum types. The sum type composed of fewer types is the supertype of the sum type composed of more types.
  • We learned about covariant types. Arrays and collections are often covariant, and function types are covariant in their return types.
  • In some languages, types can be invariant (have no subtyping relationship) even if their underlying types have a subtyping relationship.
  • Function types are usually contravariant in their argument types. In other words, their subtyping relationship is the reverse of that of their argument types.
  • In TypeScript, functions are bivariant in their argument types. As long as their argument types have a subtyping relationship, each function type is a subtype of the other.
  • Variance is implemented differently in different programming languages. It’s good to know how your programming language of choice establishes subtyping relationships.

Now that we’ve covered subtyping at length, we’ll move on to the one major application of subtyping we haven’t talked about much: object-oriented programming. In chapter 8, we will go over the elements of OOP and their applications.

Answers to exercises

Distinguishing between similar types in TypeScript

1

Yes—Painting has the same shape as Wine, with an additional painter property. In TypeScript, due to structural subtyping, Painting is a subtype of Wine.

2

No—Car is missing the name property that Wine defines, so even with structural subtyping, Car cannot be substituted for Wine.

 

Assigning anything to, assigning to anything

1

Yes—never is a subtype of any other type, including number, so we can assign it to a number (even though we would never be able to create an actual value, as makeNothing() would never return).

2

No—unknown is a supertype of any other type, including number. We can assign a number to an unknown, but not vice versa. First, we have to ensure that the value returned from makeSomething() is a number before we can assign it to x.

 

Allowed substitutions

1

Yes—We can substitute a Triangle wherever a Shape is expected.

2

No—We cannot use a supertype instead of a subtype.

3

Yes—Arrays are covariant, so we can use an array of Triangle objects instead of an array of Shape objects.

4

Yes—Functions are bivariant in their arguments in TypeScript, so we can use (shape: Shape) => void as (triangle: Triangle) => void.

5

Yes—Functions are bivariant in their arguments in TypeScript, so we can use (triangle: Triangle) => void as (shape: Shape) => void.

6

No—Functions are bivariant in their arguments but not in their return types in TypeScript. We can’t use a function of type () => Shape as a function of type () => Triangle.

 

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.184.214