Search code examples
reactjstypescriptcontravariancerecoiljs

What does 'contravariance' on AbstractRecoilValue<T> mean in Recoil source type declarations?


I stumbled upon this part of typescript/index.d.ts in Recoil source:

export class AbstractRecoilValue<T> {
    __tag: [T];
    __cTag: (t: T) => void; // for contravariance

    key: NodeKey;
    constructor(newKey: NodeKey);
}

How does __cTag serve as a discriminator for contravariance here?

Here is whole context: Github


Solution

  • I think "for contravariance" should probably say "to prevent covariance" instead, but I'll explain what's going on in any case. Let's say you have a type function type F<T> = .... If you have two types A and B where B extends A, like

    interface A { a: string }
    interface B extends A { b: number }
    
    declare let a: A;
    declare let b: B;
    a = b; // okay
    b = a; // error
    

    what, if anything, can you say about the relationship between F<A> and F<B>? There are two interesting cases to consider:

    F<T> is covariant in T: whenever B extends A, then F<B> extends F<A>. Since widening T will widen F<T> and narrowing T will narrow F<T>, you can say that F<T> varies together with T. It "co-varies", or is covariant. Function types are covariant in their return types. In TypeScript, object types are considered to be covariant in their property types (even though this is unsound when writing, it is useful). Array<T> is covariant in T (even though this is unsound when writing, it is useful). And so [T] is covariant in T.

    F<T> is contravariant in T: whenever B extends A, then F<A> extends F<B>. Since widening T will narrow F<T> and narrowing T will widen F<T>, you can say that F<T> varies opposite to T. It "counter-varies", or is contravariant. Function types (with the --strictFunctionTypes compiler option enabled) are contravariant in their parameter types. So (t: T) => void is contravariant in T. In TypeScript, object types are also considered to be contravariant in their property key types.

    In the above examples, when we say "F<T> is covariant in T" we also mean that it is not contravariant. And vice versa; when we say "F<T> is contravariant in T" we also mean that it is not covariant. So [T] is covariant (but not contravariant) in T, and (t: T) => void is contravariant (but not covariant) in T. But we can consider this case too:

    F<T> is bivariant in T: F<T> is both covariant and contravariant in T. In a fully sound type system this would not happen unless F<T> did not depend on T at all. But in TypeScript, method types (or all function types with --strictFunctionTypes disabled) are considered bivariant in their parameter types. Another situation of unsound-but-useful.

    And finally:

    F<T> is invariant in T: F<T> is neither covariant nor contravariant in T. There's no simple relationship between F<A> and F<B> when B extends A. This tends to be the most common situation. Covariance and contravariance are "fragile" in the sense then if you compose covariant and contravariant types you tend to get invariant types. This is what happens when you combine [T] and (t: T) => void in the AbstractRecoilValue<T> definition. The _tag property is covariant (but not contravariant) in T, and the _cTag property is contravariant (but not covariant) in T. By putting them together, AbstractRecoilValue<T> is invariant in T.


    So presumably _cTag was added so that AbstractRecoilValue<T> would be invariant in T and not covariant in T. You can see the difference in behavior if you comment it out:

    declare class AbstractRecoilValue<T> {
      __tag: [T];
      // __cTag: (t: T) => void; // for contravariance
      key: NodeKey;
      constructor(newKey: NodeKey);
    }
    
    declare let arvA: AbstractRecoilValue<A>;
    declare let arvB: AbstractRecoilValue<B>;
    
    arvA = arvB; // okay
    arvB = arvA; // error!
    

    You can see that AbstractRecoilValue<B> is assignable to AbstractRecoilValue<A>, but not vice versa. So AbstractRecoilValue<T> is covariant in T. But if we restore __cTag:

    declare class AbstractRecoilValue<T> {
      __tag: [T];
      __cTag: (t: T) => void; // for contravariance
      key: NodeKey;
      constructor(newKey: NodeKey);
    }
    
    declare let arvA: AbstractRecoilValue<A>;
    declare let arvB: AbstractRecoilValue<B>;
    
    arvA = arvB; // error!
    arvB = arvA; // error!
    

    then neither assignment is acceptable, and so AbstractRecoilValue<T> is invariant in T.


    If you want to know why such a restriction was placed on AbstractRecoilValue<T>, I can't answer that, since I'm not sure what it's used for... that seems to be out of scope for the question as asked, though.

    Playground link to code