typescript

TypeScript: Why does adding a second abstract method affect parameter type checking in derived class?


I encountered an interesting TypeScript behavior where the presence of a second abstract method affects type checking of the first method's parameters. Here are two cases:

Case 1 (No Error):

abstract class A {
  abstract method(a: A): void
  // abstract method2(a: B): (keyof this)[]  // Commented out
}

class B extends A {
  b: number | undefined
  method2(a: B): (keyof this)[] {
    return []
  }
  method(a: B) {}
}

Case 2 (Has Error):

abstract class A {
  abstract method(a: A): void
  abstract method2(a: B): (keyof this)[]  // Uncommented
}

class B extends A {
  b: number | undefined
  method2(a: B): (keyof this)[] {
    return []
  }
  method(a: B) {}  // Error here
}

In Case 2, TypeScript reports:

Property 'method' in type 'B' is not assignable to the same property in base type 'A'.
Type '(a: B) => void' is not assignable to type '(a: A) => void'.
Types of parameters 'a' and 'a' are incompatible.
Property 'b' is missing in type 'A' but required in type 'B'.

The only difference between these two cases is commenting out method2 in the abstract class. Why does the presence of method2 affect the type checking of method's parameter? I would expect the type checking behavior to be consistent in both cases.

You can see this behavior in the TypeScript Playground

{
  "compilerOptions": {
    "target": "es2016",
    "lib": ["dom", "dom.iterable", "esnext"],
    "resolveJsonModule": true,
    "allowJs": true,
    "strict": true,
    "skipLibCheck": true,
    "jsx": "react",
    "declaration": true,
    "declarationDir": "types",
    "sourceMap": true,
    "outDir": "dist",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "emitDeclarationOnly": true
  }
}

Can someone explain what's happening here? What causes TypeScript to enforce stricter type checking when the second abstract method is present?


Solution

  • There's a lot going on here. The TL;DR is that TypeScript's type system is neither sound (all unsafe operations are prohibited) nor complete (all safe operations are allowed). There's a tradeoff between usability and safety, so some features of the language intentionally allow unsafe things or prohibit safe things, with the ultimate goal of trying to make real-world code better. This is necessarily heuristic in nature, and so if you probe the edges of these features you will find inconsistencies. It's the nature of the language, for better or worse. See microsoft/TypeScript#41495 for a similar issue to the one you're hitting.


    Methods are checked bivariantly

    Even when you have the --strictFunctionTypes compiler option turned on, method parameters are checked bivariantly (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript). This is unsafe, but allowed because the alternative is worse. If you write code like

    class A {
      method(a: A) { }
    }
    
    class B extends A {
      b: number = 1;
      method(a: B) {
        console.log(a.b.toFixed(1))
      }
    }
    

    Then the method() method of B is technically unsafe, but TypeScript allows it. Overriding a method safely involves widening parameters, but you have narrowed a parameter from A to B. If you then try to use a B as if it's an A, you can easily get a runtime error without a compiler error

    const a: A = new B();
    a.method(new A()); // runtime error
    

    Apparently in practice this sort of mistake is rare enough to be allowed, especially because fixing it would break a lot of things people want (like Array covariance or using various DOM class hierarchies as type hierarchies).

    This bivariance is why the version of your code with the commented-out method2 is allowed. Your method()'s call signature is technically unsafe (although the actual implementation is safe because it doesn't do anything), but method bivariance allows it.


    Generics check variance more accurately

    If you use generic methods, though, then TypeScript will become stricter about variance. For example if I rewrite the above code so that the parameter is generic constrained to the current class, then you get an error pointing out that B's method() is bad, for the reason that b is required in B but missing in A:

    class A {
      method<T extends A>(a: T) { }
    }
    
    class B extends A {
      b: number = 1;
      method<T extends B>(a: T) { // error!
        console.log(a.b.toFixed(1))
      }
    }
    
    const a: A = new B(); // error
    

    And it even complains about assigning a B to an A, so you are given adequate warning that a runtime error is possible. But this code is functionally identical to the bivariant version above with no compiler errors. It's just that for generics, TypeScript assumes that people mean to constrain things in a safer way.


    The polymorphic this type is implicitly generic

    Of course your example isn't explicitly generic, so it might seem that the above section is unrelated. But, your example does use the polymorphic this type, as implemented in microsoft/TypeScript#4910. According to that pull request:

    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 ... That in particular turns a lot of previously non-generic classes into generic equivalents

    So use of the this type activates TypeScript's generics machinery, and you start getting errors:

    class A {
      method(a: A) { }
      method2(): (keyof this)[] { return [] } // added this method
    }
    
    class B extends A {
      b: number = 1;
      method(a: B) { // error!
        console.log(a.b.toFixed(1))
      }
    }
    
    const a: A = new B(); // error
    

    And this is what you're seeing in your example.


    Of course I'm glossing over details about exactly where it matters that you use the this type. I'm sure there are some places you could put the type where it wouldn't trigger the error, but I'm not inclined to probe too deeply at this particular edge case. As mentioned in microsoft/TypeScript#41495:

    The current behavior arises from a set of consciously-chosen trade-offs. We definitely could have made other trade-offs, ... but we didn't - that isn't a bug.

    It's inconsistent, but intentionally so.

    Playground link to code