typescriptcovariancecontravariancegeneric-variance

Workaround for TypeScript not inferring contravaraince?


TypeScript doesn't seem to infer contravariance. Here is an example that illustrates the inconsistency:

class Base { base = "I'm base" }
class Der extends Base { der = "I'm der" }

interface Getter<E> { get(): E }
interface Setter<E> { set(value: E): void }

type Test1 = Getter<Der> extends Getter<Base> ? 'Yes' : 'No' // "Yes"
type Test2 = Getter<Base> extends Getter<Der> ? 'Yes' : 'No' // "No"

type Test3 = Setter<Der> extends Setter<Base> ? 'Yes' : 'No' // "Yes"
type Test4 = Setter<Base> extends Setter<Der> ? 'Yes' : 'No' // "Yes"

I would expect Test3 to be "No", since that's also letting you do this (which is just the inverse of passing Getter<Base> to a function that expects a Getter<Der>):

const setBase = (setter: Setter<Base>) => setter.set(new Base()) 

const derSetter = {
    set: (thing: Der) => console.log(thing.der.toLowerCase())
}

setBase(derSetter); // Cannot read property 'toLowerCase' of undefined!

In my specific case, I'm working on some code that reads values from HTML elements, so there's an interface called Property that is similar to Setter, except set is now called get since although it takes an element, it gets a value (the element is the "in" or contravariant thing):

interface Property<E extends Element> {
    get(element: E): string
}

class ElementProperty<E extends Element, P extends Property<E>> {
    constructor(
        private readonly element: E, 
        private readonly property: P
    ) { }

    get value() { return this.property.get(this.element) }
}

Case 1 (fine):

new ElementProperty(document.head, {
    get(element: Element) { return element.outerHTML.toLowerCase(); } // No prob, every element has outerHTML.
})

Case 2 (bad):

const element: Element = document.body;

new ElementProperty(element, {
    get(element: HTMLLinkElement) { return element.href.toLowerCase(); } // The body doesn't have an href!
})

Note that if I add a method that returns E in my Property interface (and implement it on the instances), typescript will correctly complain about case 1, but not about case 2.

Is there a workaround I can use to trick typescript into preventing me from doing something like case 2? I don't care if I lose variance altogether, something like this would be fine if it was possible:

class ElementProperty<E extends Element, P === Property<E>> { ... }

Solution

  • By default, Typescript treats function parameter types as "bivariant", meaning a function type is assignable to another if its parameter type is either a supertype or a subtype of the other's parameter type. This is one of various features where Typescript's behaviour is unsound by design, in order to make it easier to convert existing Javascript codebases to Typescript.

    To disable this and have contravariant parameter types, use the strictFunctionTypes compiler option, described in the Typescript docs. Note that this only applies to function types, not method types; method parameters are still treated as bivariant even with strictFunctionTypes enabled. So you have to declare your methods as function-typed properties like this:

    interface Getter<E> { get: () => E }
    interface Setter<E> { set: (value: E) => void }
    

    Playground Link