typescriptinheritancesubtyping

Inheritance vs. subtyping in TypeScript - a bug?


I code in TypeScript and it seems to allow to produce a non-type safe code. I'm using all the "strict" options I have found. The behaviour I notice is strictly against "Inheritance implies subtyping", as discussed for instance in:

https://stackoverflow.com/questions/50729485/override-method-with-different-argument-types-in-extended-class-typescript

The code that raises no type errors is the following:

abstract class A {
    abstract do(x: number | string): number;
}

class B extends A {
    override do(x: number): number {
        return x;
    }
}

const a: A = new B();

const x: number = a.do("dupa");

console.log(x);

I would expect an error like

Error:(7, 14) TS2416: Property 'do' in type 'B' is not assignable to the same property in base type 'A'.
  Type '(x: number) => number' is not assignable to type '(x: string | number) => number'.
    Types of parameters 'x' and 'x' are incompatible.
      Type 'string | number' is not assignable to type 'number'.
        Type 'string' is not assignable to type 'number'.

Instead, I get a console output of "dupa".

I tried changing types (X, Y) = (number, string) to other pairs, assuming that some implicit casting may be performed. But I get the same effect with other types, like arbitrary, non-assignable types X and Y, or even some X and Y=null (I work with strictNullChecks).

Also, I am capable of generating type error Type 'string | number' is not assignable to type 'number'.   Type 'string' is not assignable to type 'number'. So in general such assignment is not legal.

As noted below, it seems that "it is a feature not a bug", see https://github.com/microsoft/TypeScript/issues/22156

Thus, I would like to reformulate the question:

Is there a workaround, which forces the type checker of TypeScript to detect such lack of contravariance in parameter types of overriden methods?


Solution

  • In order for TypeScript to be truly sound or type-safe, it would need to compare all function and method parameters contravariantly so that method overrides could only widen but not narrow the parameter types. (See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for more discussion of variance.)

    But TypeScript is not truly sound, nor is it intended to be; see TypeScript Design Non Goal #3, where it says that it is not a goal to "apply a sound or 'provably correct' type system" and that the actual goal is to "strike a balance between correctness and productivity."

    In TypeScript method parameters are actually compared bivariantly, so you are allowed to both widen and narrow parameter types when overriding methods. Originally this was true of all function types, but the introduction of the --strictFunctionTypes compiler option made non-method function types strict. But with or without that option enabled, method parameters are still compared bivariantly.

    This is for several reasons. One reason is that most developers want to treat Array<T> as being covariant in T, so an Array<Dog> is also an Array<Animal>:

    interface Animal { move(): void; }
    interface Dog extends Animal { bark(): void; }
    const dogs: Dog[] = [];
    const animals: Animal[] = dogs; // okay
    animals.forEach(a => a.move()); // okay
    

    This is unsound because if you push() a Cat onto an Array<Animal> which is actually an alias to an Array<Dog>, you've broken things:

    interface Cat extends Animal { meow(): void; }
    const cat: Cat = { move() { }, meow() { } };
    animals.push(cat); // okay
    dogs.forEach(d => d.bark()) // ERROR AT RUNTIME
    

    But such unsoundness is already part of the way TypeScript works with properties. Properties are covariant even though you can write to them, so the same thing happens even without arrays:

    const dog: Dog = { move() { }, bark() { } };
    const dogCage = { resident: dog };
    const animalCage: { resident: Animal } = dogCage; // okay
    animalCage.resident = cat; // switcharoo
    dogCage.resident.bark(); // ERROR AT RUNTIME AGAIN
    

    So forcing methods to be sound when properties aren't is sort of "too little, too late" for the type system. One could tighten things up by forcing properties to be invariant, or only allowing covariance on readonly properties, etc., but this turns out to be very annoying for the vast majority of use cases where people do the right thing.

    Still, they were considering enforcing this for methods with --strictFunctionTypes. But unfortunately, according to the documentation, "during development of this feature, we discovered a large number of inherently unsafe class hierarchies, including some in the DOM." So some existing native JavaScript class hierarchies violate this rule, and enforcing it would put TypeScript in the unfortunate position of invalidating native JavaScript in some way.

    Rather than do this, they made the pragmatic decision to keep allowing unsafe method overrides, while prohibiting unsafe standalone functions and callback function types.


    So if you need a workaround, you could refactor your code to use function-valued properties instead of methods, at least syntactically in your type definitions. For example:

    interface A {
        do: (x: number | string) => number;
    }
    
    class B implements A {
        do(x: number): number { // error!
            return x;
        }
    }
    

    Here I've turned A into an interface because you're not allowed to override a class's function-valued property with a method, even if it's abstract (see microsoft/TypeScript#51261). If you need the parent to be a class you could possibly just use function-valued properties instead of methods, although these change whether they exist on instances or the prototype:

    abstract class A {
        abstract do: (x: number | string) => number;
    }
    
    class B extends A {
        override do = function (x: number) { // error            
            return x;
        }
    }
    

    Playground link to code