typescriptsubclasscovariantcontravariant

How to declare a contravariant function member in TypeScript?


I have a class that contains a member that is a function that takes an instance of the class:

class Super {
    public member: (x: Super) => void = function(){}
    use() {const f = this.member; f(this)}
}

but I want member to be contravariant; specifically, I want to allow subclass instances to accept member values that are functions that take that specific subclass, i.e.:

class Sub extends Super {
    method() {}
}

const sub = new Sub();
sub.member = function(x: Sub) {x.method()};

but tsc quite correctly complains:

Type '(x: Sub) => void' is not assignable to type '(x: Super) => void'.
  Types of parameters 'x' and 'x' are incompatible.
    Property 'method' is missing in type 'Super' but required in type 'Sub'.

How do I declare member such that it can in subclasses be a function that takes a covariant (rather than contravariant) parameter type?


What I've tried:

  • I know that if I declare member using method syntax (member(s: Super) {/* ... */}) then it will be bivariant, but this does not help in situations where member might be a collection of functions (e.g., in my actual code the type of member is a dictionary of such functions: {[name: string]: (/*...*/, s: Super) => /*...*/}).

  • I attempted to redeclare member in Sub with a more restrictive signature:

    class Sub extends Super {
        public member: (x: Sub) => void = function(x){x.method()};
        method() {}
    }
    

    but tsc steadfastly refuses to let me aim the gun at my foot:

    Property 'member' in type 'Sub' is not assignable to the same property in base type 
    'Super'.
      Type '(x: Sub) => void' is not assignable to type '(x: Super) => void'.
        Types of parameters 'x' and 'x' are incompatible.
          Property 'method' is missing in type 'Super' but required in type 'Sub'.
    
  • I understand that typescript now supports in and out modifiers on templates to denote co/contravariance but I am not sure if they are applicable nor how to turn Super into a suitably-templated declaration.

  • I'd rather not turn off strictfunctionTypes, as it is generally useful and I don't want to force users of this library to turn it off in order to assign to .member on subclass instances.

  • As a last resort I can just cast the values being assigned to as (x: Super) => void, but this removes protection against assigning to the wrong subclass, e.g.:

    class Sub1 extends Super {
        method1() {}
    }
    class Sub2 extends Super {
        method2() {}
    }
    
    const sub1 = new Sub1();
    sub1.member = function(x: Sub2) {x.method2()} as (x: Super) => void;
    

    is accepted by tsc but fails at runtime.

  • Checking the related questions, I see a similar question involving interfaces rather than subclasses, but it has no formal answers yet and I do not fully understand the snippets linked in the comments; they appear to depend on being able to fully enumerate all of the subtypes, which is not suitable for my situation where there may be an arbitrary number of (sub)*subclasses.


Solution

  • It looks like you might want the polymorphic this type, which acts like an implicit generic type parameter that is always constrained to the "current" class. So inside the Super class body, the this type refers to "some subtype of Super", while inside the Sub class body it refers to "some subtype of Sub". For instances of Super, the this type will be instantiated with Super, and for Sub it will be instantiated with Sub.

    That is, inside the class body, this acts like a generic parameter, and outside the class body it behaves like that parameter has been specified with a type argument corresponding to the current object type.


    That gives you the desired behavior with your example code:

    class Super {
        public member: (x: this) => void = function () { } 
        use() { const f = this.member; f(this) }
    }
    
    
    class Sub extends Super {
        method() { }
    }
    
    const sub = new Sub();
    sub.member = function (x: Sub) { x.method() }; // okay
    

    Looks good.


    Note that you could simulate this behavior by using generics explicitly (using a recursive, F-bounded constraint reminiscent of Java):

    class Super<T extends Super<T>> { 
        public member: (x: T) => void = function () { }
        use(this: T) { const f = this.member; f(this) } 
    }
    
    class Sub extends Super<Sub> {
        method() { }
    }
    
    const sub = new Sub();
    sub.member = function (x: Sub) { x.method() };
    

    which is less pretty but gives you some more flexibility, if this types by themselves don't meet your needs.

    Playground link to code