December 09, 2022

Pitfalls in TypeScript - Broken Liskov Substitution Principle for Fields

While improving support for using TypeScript definitions within N4JS, we discovered some peculiarities that every TypeScript programmer should keep in mind. In this post we present how subtypes are allowed to break their supertypes' api.

 

Subtypes can specialise their parent type or leave it as is. This specialisation can be done via either adding some members like fields or methods, or by overriding members and widen or narrow their types. However, manipulating types is very tricky and can only be done depending on how members are accessed. In general, the Liskov substitution principle applies.



Liskov substitution principle:

Subtype Requirement: Let  be a property provable about objects  of type T. Then  should be true for objects  of type where is a subtype of T.

-- Barbara Liskov and Jeannette Wing, 1994


In other words: Whatever you can do with an object typed T you should also be able to do with an object typed S, where S is a subtype of T. However, let's have a look at what is possible in TypeScript but questionable from a type safety point of view (and hence not allowed in N4JS, Java, and other languages):

 

class C1 {
fieldC1: boolean = true;
}
class C2 extends C1 {
    // Liskov substitution principle violated here since type of 'fieldC1' gets narrowed
override fieldC1: true = true; // note: 'true' is a subtype of 'boolean'
}

const c2 = new C2();
c2.fieldC1 = false; // TypeScript ERROR: Type 'false' is not assignable to type 'true'.

const c1: C1 = c2; // up-cast
c1.fieldC1 = false; // assign 'false' via supertype

console.log(c2.fieldC1) // yields print out: "false"

if (c2.fieldC1 == false) { // TypeScript ERROR: This comparison appears to be unintentional because the types 'true' and 'false' have no overlap.
    // actually this is reachable
}

What we see in the example above is how the Liskov substitution principle was broken for fields in TypeScript: We cannot do with c2 what we are able to do with c1, despite the fact that the type of c2 is a subtype of the type of c1. In order to preserve the Liskov substitution principle we know from languages like Java, N4JS and others, that we are neither allowed to narrow nor to widen the types of fields along the type hierarchy. This is called type invariance. In TypeScript however, covariance is allowed, that means that the type of fields can be narrowed to a subtype, like shown in the example above.

by Marcus Mews