Repository navigation
Covariance / Contravariance Annotations #1394
Description
Activity
- changed the title
[-]Covariance / Contravariance[/-][+]Covariance / Contravariance Annotations[/+]on Dec 8, 2014 - addedQuestionAn issue which isn't directly actionable in codeAn issue which isn't directly actionable in codeSuggestionAn idea for TypeScriptAn idea for TypeScript
on Dec 8, 2014 The assignments you've noted are by design. When checking assignability of function types their parameters are considered bivariant. So when assigning
fa = fbit is valid iffa's parameters are assignable tofb's or vice versa. Likewise for the function typed members ofArraywhen determining whetheras = bsis valid. See #274 for the suggestion to check these more strictly all the time.Co/contravariance is not an easy concept for a lot of folks to grasp (even though there is an intuitive nature to the assignment questions). Explicit annotations for them can be especially difficult for people to make use of (particularly if they have to author the annotations in their own code). C# has these types of annotations now but was highly successful for many years without the additional layer of checking the variance annotations afford. My gut feeling is that I would be surprised if these ended up in TypeScript given their complexity and how 'heavy' a concept they are.
Reacted by SlimDan Quirk (@danquirk) I disagree that covariance/contravariance are such "heavy" concepts. In practice, it's generally just library authors that use them (not end users), and they're easy to ignore, so I don't think you're adding a burden to the language. Yet on the flip side, you're adding a lot of expressiveness to situations that would otherwise be less type safe.
Also, at a syntax level- having optional keywords of "in" and an "out" seems to me to be as lightweight as language constructs get.
I certainly wouldn't consider this feature high enough priority to want it added to the backlog anytime soon, but once TypeScript is more mature it could certainly be a useful addition.
Igorbek commented
on Dec 9, 2014 ContributorAuthorMore actionsSo, if go with some "strict" mode only, there's no way to annotate variance, because it shouldn't introduce any new keywords.
I believe in most cases, TypeScript will be able to infer variance from usage. And bivariance for functions could be a fallback if modifier wouldn't be specified.I've read Function Argument Bivariance article, and it looks like the example might be rewritten more clear and useful without bivariance requirement:
enum EventType { Mouse, Keyboard } interface Event { timestamp: number; } interface MouseEvent extends Event { x: number; y: number } interface KeyEvent extends Event { keyCode: number } function listenEvent<TEvent extends Event>(eventType: EventType, handler: (n: TEvent) => void) { /* ... */ } listenEvent<MouseEvent>(EventType.Mouse, e => console.log(e.x + ',' + e.y));
Sam (@MgSam) thanks for support.
Outputs are always covariant and arguments are usually contravariant. The only point where we'd need an annotation is when an argument is also used as an output. I think it would be a lot easier to grasp if all type constructors and generics came equipped with their associated
mapfunctions, since then you just look at what order the mapped functions get applied.Igorbek commented
on Dec 13, 2014 ContributorAuthorMore actionsMike Stay (@metaweta) callbacks are bivariant now. It also needs clarification/make stricter.
I don't know what you mean by "callbacks are bivariant": variance is a concept that applies to a specific use of a type in a signature. I was suggesting that types used in arguments be contravariant by default; now that I think more about it, that would cause new errors in currently working code, which is unacceptable.
To avoid the issue of a new keyword, I suggest using +/- like Scala does.
How about a directive "use variance" to opt into that assumption? That way a library author can avoid having to add the contravariant modifier everywhere, while the user of the library doesn't have to worry about it. Directives are a part of ECMAScript specifically designed for this kind of scoped alteration of semantics.
Igorbek commented
on Dec 13, 2014 ContributorAuthorMore actionsI mean that callback is an argument which is a function:
class A { ... } class B extends A { ... } class C extends B { ... } function f(callback: (b: B) => void) { ... } f((x: A) => { ... }); // no error, callback accepts x: A, fair any B is A f((x: C) => { ... }); // no error, callback accepts x: C, but it might be called with B which isn't C
That's what "callback are bivariant" means.
Regarding +/-, do you mean to use it with type specifier? Like:
function f<T>(x: T-[], y: T) { x.push(y); } // y: T+, by default for function arguments
Directives are a part of ECMAScript specifically designed for this kind of scoped alteration of semantics.
Which directives do you mean?
The arrow functor is contravariant in its first argument and covariant in its second. Contravariance acts somewhat like multiplication by -1.
In
(a:A) => B,Ais contravariant andBis covariant.In
(f: (a: A) => B) => C,Cis covariant and(a: A) => Bis contravariant, which means thatAis covariant (-1 * -1 = 1) andBis contravariant (-1 * 1 = 1).Regarding +/-, do you mean to use it with type specifier?
I mean to use it in the type parameters rather than the function signature:
interface Foo <-A, +B> { map<+C>(f:(a:A) => C): Foo<C, B>; }
Which directives do you mean?
The directive
"use strict"changes the semantics of the language within a function block or program production (usually an HTML script block). I imagine a"use variance"directive in a module production.Igorbek commented
on Dec 13, 2014 ContributorAuthorMore actionsThe arrow functor is contravariant in its first argument and covariant in its second. Contravariance acts somewhat like multiplication by -1.
I understand this. But now TS violates this rule in case of callbacks. I didn't say how it should work, I did say how it works now.
Yikes! That's terrible.
RyanCavanaugh commented
on Dec 17, 2014 MemberMore actionsIf function parameters weren't bivariant,
Array<Dog>would not be a structural subtype ofArray<Animal>due to members likeforEach.I take back my "that's terrible" assesment; that would only be fair for a purely functional language. Every mutable data structure is going to have this trouble. Getters are covariant while setters are contravariant: given
f:(x:X)=>Y, andarr:Array<X>,arr.map(f)is an array where a getter returns aY; for instance, this could be implemented lazily by having a getter look up an elementxand then returnf(x). A map usingXcontravariantly would take a functiong:(w: W)=>Xandarr.comap(g)would be an array where the setter would storeg(w)in the array. (I'm not suggesting comap be implemented or that map have a different implementation, just pointing out how the variance shows up in getters and setters.)I think the article's right that the sound alternatives are too cumbersome for not enough benefit.
28 remaining items
With the available tools present in the language, we are nearly to the point where having functions default to contravariant will accomplish most usage scenarios:
declare func((x: string) => void); func(() => { }); // Valid func((x: 'foo') => { }); // Compiler error // Want a covariant function? Generics will let you write one declare func2<S extends string>((x: S) => void); func(() => { }); // Invalid func2((x: 'foo') => { }); // Valid // How about a function that can operate on an array of any type of Animal? declare function sortByWeight<A extends Animal>(animals: Array<A>): void;
Edit: adjusted last examples.
Reacted by Artur Eshenbrener, Magnus Hiie and Kirill AgalakovRegarding the argument that co-/contravariance are hard-to-grasp concepts - I think this is irrelevant considering that interface assignment compatibility tracks co-/contravariance even without annotations, and therefore in order for a person to understand what's going on, they need to understand the co-/contravariance (though I think it's covariance vs bivariance currently) distinction anyway.
interface Setter<T> { set(value: T); } let sA1: Setter<{}> = { set() {} }; const sA2: Setter<number> = sA1; let sB1: Setter<number> = { set(value: number) {} }; const sB2: Setter<{}> = sB1; // succeeds due to parameter bivariance interface Getter<T> { get(): T; } let gA1: Getter<{}> = { get() { return {}; } }; const gA2: Getter<number> = gA1; // Type '{}' is not assignable to type 'number'. let gB1: Getter<number> = { get() { return 1; } }; const gB2: Getter<{}> = gB1; // succeeds due to covariant returns
For a long time, I didn't understand that TypeScript tracks the generic variance, and therefore the distinction between Promise and Iterable was totally confusing - both are reader interfaces, and same variance should apply.
EDIT: Or rather, I understand now that structural typing results in effects as if TypeScript tracked variance across interface boundaries.
With the recent addition of
--strictFunctionTypesco- and contra-variance are now supported on function types without function types. This was a major step up and we had to fix dozens of actual issues in our code (which is awesome!).Are there any plans to also support co- and contra-variance for other types? (as reported in this issue)? Or nothing on the roadmap?
Igorbek commented
on Jan 10, 2019 ContributorAuthorMore actionsThe proposal initially discussed here and later outlined primarily in #10717 is mostly concerned of use-site covariance annotations. The motivation use cases are still not addressed even with recent enstricten rules.
The simplest example is an array type which can be used both co- and contravariantly. With the proposal an array of type
Array<out T>can only be used where typeTin covariant positions (getters, methods likepop). SimilarlyArray<in T>can be used where typeTin contravariant positions (setters, methods likepush). Note, in general,Array<out T>is not the same asReadonlyArray<T>.Igor Oleinikov (@Igorbek) Could you elaborate on how
Array<out T>would be different fromReadonly<Array<T>>(notReadonlyArray<T>- I'm not including methods here) orArray<in T>from a theoreticalWriteonly<Array<T>>(assuming someWriteonlyanalogue to the built-intype Readonly<T> = {+readonly [P in keyof T]: T[P]})?Igorbek commented
on Jan 10, 2019 ContributorAuthorMore actions@isiahmeadows consider
Array's methods likepop,reverse,shift,sort, and many others. Although they are modifying array and are excluded fromReadonlyArray<T>, the typeTthere is in covariant position and therefore is part ofArray<out T>.Igor Oleinikov (@Igorbek) Re-read my comment. You missed my nuance between
Readonly< Array< T > >andReadonlyArray< T >(spaces here added for emphasis).Igorbek commented
on Jan 11, 2019 ContributorAuthorMore actionsah, sorry @isiahmeadows I thought you were asking in the context of my previous note about their differences.
In fact,
Readonly<Array<T>>is even less covariant in respect toTthanReadonlyArray<T>.
Assuming it is defined astype Readonly<T> = { readonly [P in keyof T]: T[P]; }, it only transforms own fields (not even methods) to become read-only.Actually, TS does not currently make
Readonly<T[]>be actually read-only:function test<T>(a: Readonly<T[]>, v: T) { a[0] = v; // no error }
In general, generic type variance has nothing to do with read/write-ability.
A simple test would be:interface X<T> { value: T; // read-write field with T in covariant position for reads and in contravariant position for writes set: (value: T) => void; // read-write field with T in contravariant position } type Readonly<X<T>> = { // effectively equivalent to this readonly value: T; // this is now covariant in respect to T readonly set: (value: T) => void; // read field with T in contravariant position } type X<out T> = { readonly value T; // same as Readonly writeonly set: (value: T) => void; // see the difference } type X<in T> = { writeonly value T; readonly set: (value: T) => void; }
And a final thing, even if in many respects having a readonly version is mostly what you want, a simple
Readonly<T>cannot deal with multiple type arguments. Like imagingProcessor<TIn, TOut>is contravariant in respect toTInand covariant in respect toTOut, and therefore cannot be expressed withReadonly/Writeonlyas it have mixed type arguments' variance.Okay, I'd find
a[i] = 0succeeding whena: Readonly<number[]>to be a bug.Igorbek commented
on Jan 16, 2019 ContributorAuthorMore actionsOkay, I'd find
a[i] = 0succeeding whena: Readonly<number[]>to be a bug.being fixed in #29435
In the case of arrays, why not prevent aliasing assignments that aren't invariant without an explicit cast?
class Animal {} class Dog extends Animal { bark() { } } class Cat extends Animal { purr() { } } const cats: Array<Cat> = []; const goodAnimals: Array<Animal> = [new Cat, new Cat]; const badAnimals: Array<Animal> = cats; // why not make this an error? goodAnimals.push(new Dog); // okay badAnimals.push(new Dog); // hard to prevent, how do you tell goodAnimals from badAnimals? cats.forEach(cat => cat.purr()); // oops!Given TypeScript's support for generics, when are covariant arrays actually useful?
Reacted by Michael Sholty and ryan-0324Reacted by Max Heiber and xiao xin- added a commit that references this issue
on Aug 8, 2020 I am very excited to see that this will exist in future versions of TypeScript.
For the time being, if you're looking for a hacky way to ensure contravariance, you can add something like this to a class:
export class C<T> { protected __forceTToBeContravariant?: (t: T) => void;Note that you can't make this private if you want the constraint to work across TS projects due to #38953 ; you might think it works in your same-project tests but not once it is encoded in
.d.ts!
(It's a question as well as a suggestion)
Update: a proposal #10717
I've supposed that for structural type system as TypeScript is, type variance isn't applicable since type-compatibility is checked by use.
But when I had read Ryan Cavanaugh (@RyanCavanaugh) 's TypeScript 1.4 sneak peek (specifically 'Stricter Generics' section) I realized that there's some lack in this direction by design or implementation.
I wondered this code is compiled:
More clear code:
How could
B[]be assignable toA[]if at least on memberpushis not compatible. ForB[].pushit expects parameters of typeB, butA[].pushexpectsAand it's valid to call it withA.To illustrate:
Do I understand it correctly that is by design?
I don't think it can be called type-safe.
Actually, such restriction that could make
B[]to be unassignable toA[]isn't desirable.To solve it I suggest to introduce variance on some level (variable/parameter, type?).
Syntax
I'm not sure where variance should be applied - to variable or type?
Looks like it closer to variable it self, so the syntax could be like:
Questions to clarification
in out(fixed type)?innorout(open for upcast/downcast)?So this topic is a discussion point.