typescriptinheritancetypesinterfacecontravariance

contravariance issue in typescript interfaces?


Preemptive apologies if I'm abusing the term, but I'm able to implement an interface in typescript which I believe is not type safe, for example:

interface Point {
  x: number;
  y: number;
  dist (other: Point): number
}

GridPoint implements Point {
  constructor (public x: number, public x: number) {}
  dist (other: Point) { /* ... math here ... */ } //
}

NamedPoint implements Point {
  // this class has an extra `name: string` property...
  constructor (
    public x: number,
    public x: number,
    public name: string
  ) {}

  dist (other: NamedPoint) {
    // other is a NamedPoint so other.name is okay, but this
    // is not true of any Point, so how can NamedPoint be said
    // to implement point?
    if (other.name.startsWith()) { /* ... */ }
  }
}

// this will throw if `a` is a NamedPoint and `b` is a GridPoint
function getDist (a: Point, b: point) {
  console.log(`distance is: ${a.dist(b)}`)
}
// but tsc won't complain here:
getDist(new NamedPoint(1, 2, 'foo'), new GridPoint(9, 8));

link to full example on playground

Again, I'm certain that I'm phrasing this the wrong way in terms of "contravariance" but I would think that NamedPoint implements Point would be forbidden by the compiler. I thought I could get this by turning on strictFunctionTypes in tsconfig, but that apparently doesn't apply to this situation.

Is it my understanding of types that is incorrect, or is typescript wrong here? If it's the latter, can I do anything about it?


Solution

  • Your understanding of types is correct, but TypeScript is intentionally unsound when it comes to method parameters. It treats methods as being bivariant in their parameter types. As you saw, this is unsafe. But it is convenient for TypeScript to do this, since otherwise there are certain built-in JavaScript class hierarchies which would no longer make type hierarchies. It also allows people to treat types like Array<T> as covariant in T... convenient, but unsafe. See "Why Method Bivariance" in the TypeScript FAQ for their reasoning.

    This bivariance used to apply to all function types, but enabling the --strictFunctionTypes compiler option restricts this unsoundness to just method types. Therefore, the fix in your case is to redefine dist from a method to a function-typed property:

    interface Point<T> {
        x: T;
        y: T;
        dist: (other: Point<T>) => T // change
    }
    

    Nothing prevents you from implementing a function-typed property as a method, so your GridCoord class still compiles with no error. But now NamedPoint gives you the expected error:

    class NamedPoint implements Point<number> {
    
    // ✂ snip ✂ //
    
        dist(other: NamedPoint): number { // error!
      //~~~~ <-- Type '(other: NamedPoint) => number' is not 
      //         assignable to type '(other: Point<number>) => number'.
            if (other.name.startsWith(this.name)) return 0;
            else return Math.sqrt(
                (this.x - other.x) ** 2 +
                (this.y - other.y) ** 2
            );
        }
    
    }
    

    Playground link to code