Search code examples
typescript

In Typescript "extends" works, but error when assigning derived to super


The following code (at Playground here) gives an error Type 'DerivedClass' is not assignable to type 'SuperClass' at the variable obj. How is this possible, when DerivedClass extends SuperClass? Does this contradict Liskov substitution principle?

class SuperClass
{
    func(arg: (me: this) => void) {
        return arg
    }
}

class DerivedClass extends SuperClass
{
    variable = this.func(() => {});
}

const obj: SuperClass = new DerivedClass();

In an attempt to further isolate the 'problem', the following code errors for some reason, unless protected is removed in both classes:

abstract class SuperClass
{
    protected abstract variable: (me: this) => void;
}

class DerivedClass extends SuperClass
{
    protected variable = (me: this) => {};
}

const obj: SuperClass = new DerivedClass();

There are many definitions of the Liskov substitution principle, but commonly it is assumed that objects of a derived type can behave like/pretend to be a super type. I would expect an error inside the definition of DerivedClass if something is wrong with the inheritance, not on the last line of code.


Solution

  • See microsoft/TypeScript#39375.

    TypeScript is behaving as intended; when you use the polymorphic this type in a contravariant or invariant position (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript), then a subclass will no longer be considered a subtype of its base class.

    This is not a violation of the Liskov Substitution Prinicple because polymorphic this behaves as an implicit generic type parameter which is recursively bounded (also known as F-bounded). See microsoft/TypeScript#4910, the pull request implementing polymorphic this. In particular, it says that "the polymorphic this type is implemented by providing every class and interface with an implied type parameter that is constrained to the containing type itself".


    You can mostly simulate the behavior of this with an explicit F-bounded generic. For example:

    class Foo<T extends Foo<T>> {
        m = (x: T) => { }
    }
    interface FooSelf extends Foo<FooSelf> { }
    
    class Bar<T extends Bar<T>> extends Foo<T> {
        a = "z";
        m = (x: T) => {
            console.log(x.a.toUpperCase());
        }
    }
    interface BarSelf extends Bar<BarSelf> { }
    

    Here Foo and Bar are both generic in a type parameter T that is constrained to a function of itself.

    They both have a function-valued m property whose argument is of type T. Because the T in Bar is constrained to Bar<T>, then inside m you can be sure that x has all the properties of Bar, including the a property. But inside Foo's m you can't be sure of that, because the T in Foo is only constrained to Foo<T>.

    So while Bar<T> is a proper subtype of Foo<T>, you can't say the same for Bar<Bar<T>> and Foo<Foo<T>>, or Bar<Bar<Bar<T>>> and Foo<Foo<Foo<T>>>, or the recursive limit of those: FooSelf and BarSelf.

    Thus the following assignment is an error, on purpose.

    const b: BarSelf = new Bar();
    const f: FooSelf = b; // error, type 'BarSelf' is not assignable to type 'FooSelf'.
    

    If you were allowed to substitute a BarSelf where a FooSelf was needed, you'd end up with runtime errors whenever you call the m() method on a non-Bar argument:

    f.m(new Foo()); // runtime error
    

    Note that none of this would be considered an LSP violation.


    Now consider the version of the above that uses polymorphic this instead of an explicit generic:

    class Foo {
        m = (x: this) => { }
    }
    
    class Bar extends Foo {
        a = "z";
        m = (x: this) => {
            console.log(x.a.toUpperCase())
        }
    }
    
    const b: Bar = new Bar();
    const f: Foo = b; // error, Type 'Bar' is not assignable to type 'Foo'.
    f.m(new Foo()); // runtime error
    

    That's the same thing, pretty much. The generic is implicit, but it's still there. Once you use a polymorphic this type in a non-covariant position, you've set up a situation where a class or interface hierarchy ceases to be a type hierarchy. But it's not an LSP violation, just a very weird case where a class depends on itself in such a way as to make the usual rule of class X extends Y {⋯} or interface X extends Y {⋯} implies X extends Y no longer hold true.


    But even though this isn't considered a type system error, that doesn't mean that every behavior of TypeScript is sound. TypeScript is full of substitutability violations. TypeScript's type system is neither sound nor complete. Such violations aren't desirable in and of themselves, but they are essentially unavoidable in order to support idiomatic JavaScript, which is "extremely hostile toward producing a usable sound type system" (see this comment on microsoft/TypeScript#9825). There are intentional features of TypeScript where a type X is considered to be a subtype of another type Y, but if you actually substitute a value of type X for Y, then you will likely get a runtime error. For example:

    const a: { x: string } = { x: "abc" }; // allowed
    const b: { x: string | number } = a; // considered substitutable, but
    b.x = 2; // you can do this
    a.x.toUpperCase(); // RUNTIME ERROR! LSP VIOLATION
    

    Conversely there are features where a type X is not considered to be a subtype of another type Y, even though there could never be a runtime problem if you substitute a value of type X for Y:

    const c: { x: string | number } = { x: "abc" };
    const d: { x: string } | { x: number } = c; // error, but why?
    //    ~ <-- Type '{ x: string | number; }' is not assignable to 
    //       type '{ x: string; } | { x: number; }'.
    

    So even if you find something where subtyping and substitutability don't properly match, it's not necessarily a bug in TypeScript.

    Playground link to code