-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: covariance and contravariance generic type arguments annotations #10717
Comments
This reminds me a lot of Kotlin's covariant and contravariant generics On Mon, Sep 5, 2016, 23:23 Igor Oleinikov [email protected] wrote:
|
@isiahmeadows yup, it's called use-site variance. Kotlin has both use- and declaration- site variance. Check out their paper on it. I can't say whether we're committed to variance, but I highly suspect that given the way |
In my proposal I'm also pointing declaration-site variance as optional part. It must just instruct compiler to verify for variance violations in type definitions, such as you cannot have covariant type taken in contravariant position. |
@DanielRosenwasser if the inferring is too complicated, it might be a work for tooling as a first stage. Type argument without |
Please fight for this feature! One very serious limitation of the assignable to-or-from rule in 3.11.2 is that Promises are unsafe. Consider the following code
This code shouldn't be allowed to typecheck; however, this code has passed typechecking in every version of Typescript from 0.8 through 2.0, even with all strictness checks enabled. Assigning Async programming is hard enough without the compiler letting type errors slip through :-) |
Yep. That's a good reason to need it. Oh, and given the above, I think the default behavior should be changed On Tue, Sep 13, 2016, 11:47 Aaron Lahman [email protected] wrote:
|
@isiahmeadows I doubt the default will get changed. I messaged the Typescript team back in 2013 about this -- back in 2013, promises were still pretty rare things. They agreed that promises would be important, but for every example I could show that broke, they could find 100 examples of jQuery and other frameworks that would have to forego all type checking if they changed the default. I could tell it was a hard decision for them, but back then jQuery trumped promises, and I think if we're honest, most Typescript users in 2013 would have agreed with that decision. However, a lot has changed since 2013. Typescript 2.0 has shiny new type checker options. Maybe there's room in a future release to add an option for soundness. |
Good point. And of course, if you want to change the default, you have to On Tue, Sep 13, 2016, 12:07 Aaron Lahman [email protected] wrote:
|
@aaronla-ms @isiahmeadows appreciate your support guys! I'm definitely gonna fight for this. For me it seems pretty clear that current workaround where parameters are bivariant is not playing well with type safety. However I understand the TypeScript team when they're saying about complexity that could be introduced if just enforce users to write annotations always. So to keep language usage simple, inferring system must be smart enough so that means its implementation can really be challenging. Issue with promises could be solved with declaration-site variance. Let's imaging how would interface Promise<out T> { // declaration-site covariance that enforces interface verification
then<TResult>(onfulfilled?: (value: T) => TResult | PromiseLike<TResult>, onrejected?: (reason: any) => TResult | PromiseLike<TResult>): PromiseLike<TResult>;
then<TResult>(onfulfilled?: (value: T) => TResult | PromiseLike<TResult>, onrejected?: (reason: any) => void): PromiseLike<TResult>;
}
var p: Promise</*implicitly out */ number> = ... ;
var p2: Promise<{}>;
var p3; Promise<string>;
p2 = p; // ok
p3 = p2; // here an error would be cought
p3.then(s => s.substr(2)); // runtime error, no method 'substr' on number |
I'm assuming you mean with declaration-site variance and parameter contravariance. Otherwise it would still find that |
Yes, that is exactly what I meant. |
TypeScript, if I understand correctly, already has parameter contravariance On Tue, Sep 13, 2016, 16:47 Igor Oleinikov [email protected] wrote:
|
The current The following simplification of @aaronla-ms's example still compiles fine (without bivariance): var p: Promise<number> = ... ;
var p3: Promise<string>;
p3 = p;
p3.then(s => s.substr(2)); // runtime error, no method 'substr' on number |
@isiahmeadows that is not quite the same. you're pointing to type argument constrains ( |
@yortus that issue is caused by exact same issue - parameter bivariance. If promise had a property of type |
@Igorbek I think it's a separate issue. If you comment out the nullary The bivariance issue remains for subtype/supertypes however. |
(fixed the post, it was cut somehow) interface PromiseLike<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult>(onfulfilled?: (value: T) => TResult | PromiseLike<TResult>, onrejected?: (reason: any) => TResult | PromiseLike<TResult>): PromiseLike<TResult>;
then<TResult>(onfulfilled?: (value: T) => TResult | PromiseLike<TResult>, onrejected?: (reason: any) => void): PromiseLike<TResult>;
} This definition prevents assigning interface P<T> { // this is PromiseLike<P> for simplicity
then<TResult>(onfulfilled?: (value: T) => TResult | P<TResult>, onrejected?: (reason: any) => TResult | P<TResult>): P<TResult>;
then<TResult>(onfulfilled?: (value: T) => TResult | P<TResult>, onrejected?: (reason: any) => void): P<TResult>;
}
let p1: P<string>;
let p2: P<number> = p1; // compiler error (with current compiler)
let p3: P<{ a; }>;
let p4: P<{ b; }> = p3; // compiler error, again
// but, if I do
let p5: P<{ a; b; }> = p3; // ok, contravariance
let p6: P<{ b; }> = p5; // ok (P<{ a; }> assigned), covariance (so that is bivariance in total) |
@Igorbek that's all true for |
ah, ok, that makes sense. I didn't count that is other |
Btw, I remembered an old hack abusing property covariance that the Typescript folks showed me first time I hit this issue. Until you have covariance annotations, you could add a dummy optional field of type T. Properties and return values are already covariant (contravariant assignments disallowed), causing your generic to become covariant in T as well.
Is there an obvious way to extend this to contravariant type as well (e.g. |
@aaronla-ms nice trick, that technique is also used for emulating nominal types by introducing a private "brand" property. |
ref #11943 for tracking a good variance-related call to be addressed in the proposal. |
The main place where I encounter the issue is with "out" parameters, the most common being a React At the time this issue was made, React didn't use But ref objects are just The easiest example is that A heuristic I'd thought of proposing is that if all the keys of the object that you give are |
Right, in React type Ref<T> = { current: T | null; }
// React writes ref
function _reactSetRef<T>(ref: Ref<in T>, value: T | null) {
// ^^^^ allow anything that T is assignable to
ref.current = value; // ok
}
const myref = createRef<HTMLElement>();
// Props<'input'> = { ... ref: Ref<in HTMLInputElement> }
<input ref={myref} />; // ok |
React's contravariant |
I think I'm bumping into this when using slotted components in react? Say for example I have some component <HostComponent<T> renderView={ViewRenderer<T>} /> where <HostComponent<Chicken> renderView={BirdView} /> My instinct is that this should typecheck since ViewRenderer only reads T to render a component, but it doesn't in current typescript since Edit: for anyone else looking at a similar problem, the solution I came to was to reassign the identifiers through a utility type. I'm not happy with the solution, but at least it'll throw an error at the callsite if the types change in the future. /**
* Because typescript doesn't support contravariance or writeonly props to components,
* 'write-only' parameter (e.g. generic component slots) must be cast to exact types at
* the callsite.
*
* See related typescript issue
* https://github.com/Microsoft/TypeScript/issues/10717
*
* This alias checks that the type we're casting to is a subtype of the exact expected type
* so the cast site won't break silently in the future.
*/
type VarianceHack<ParentType, ChildType> = ChildType extends ParentType ? ParentType : never;
const ChickenView = BirdView as VarianceHack<
ViewRenderer<Chicken>,
typeof BirdView
>;
///...
<HostComponent<Chicken> renderView={ChickenView} /> |
I came back to this issue because we've been seeing a lot of confusion around how variance gets measured. A particular example is something that looks covariant: class Reader<T> {
value!: T;
getProperty(k: keyof T): T[keyof T] {
return this.value[k];
}
}
type A = { a: string };
type AB = A & { b: number };
function fn<T>(inst: Reader<A>) {
const s: string = inst.getProperty("a");
}
declare const ab: Reader<AB>;
// Disallowed, why?
fn(ab); It really seems like class Reader<T> {
value!: T;
someKey!: keyof T;
getProperty(k: keyof T): T[keyof T] {
return this.value[k];
}
}
type A = { a: string };
type AB = A & { b: number };
function fn<T>(inst: Reader<A>) {
const s: string = inst.getProperty(inst.someKey);
}
declare const ab: Reader<AB>;
// Legal
ab.someKey = "b";
// Causes s: string to get a number
fn(ab); Indeed if you just extracted out declare const a: A;
declare const kab: keyof AB;
declare function getProperty<T, K extends keyof T>(value: T, key: K): T[K];
// Illegal because kab could be 'b'
getProperty(a, kab); The problem is the original example here is not really contravariant/invariant without some aliasing step, and you have no way to assert that this aliasing doesn't actually occur in your program - the measured variance is the measured variance, full stop. The follow-on is that it's not clear what to do. If you let you write class Reader<covariant T> { we'd presumably just have to error on the declaration of It seems like what you want is some way to annotate specific use sites of As for use-site variance annotations, I don't think this is a good route. The variance of any particular site is purely manifested by its position; the only real missing feature here is |
@RyanCavanaugh thank you for getting back to this issue and keeping thinking of attacking it in some direction. I totally agree that this a common confusion what is variance is and how it relates to readonly-ness. It seems to me that most of the time covariance and readonly-ness are used interchangeably. However, I would argue that in my original proposal I had the same misunderstanding and, more importantly, I still insist that use-site variance has its own dedicated value for the type system correctness and expressiveness. First, I want to admit that having such a level of expressiveness definitely requires a very advanced understanding of it is and how to use it correctly. It is supposed to be used by library and type definition authors. Therefore, I agree the feature needs to be designed very carefully to not harm most regular users. No rush at all, especially many use-cases were already addressed with
There're still positions where the variance in respect to generic types cannot be manifested by its position. A simple example can show this: /** Moves data from the source array to the destination array and return the destination's final size */
function moveData<T>(source: readonly T[], destination: writeonly T[]): number {
while (source.length) {
destination.push(source.pop()); // 'pop' is not defined on ReadonlyArray<T>
}
return destination.length; // 'length' getter is not defined on WriteonlyArray<T>
} What the intent there really is not readonly/writeonly for these arrays. Is that a guarantee that only subtypes of moveData(cats, animals); // allowed
moveData(animals, cats); // disallowed The correct signature to express that would be: declare function moveData<T>(
source: { readonly length: number; pop(): T; }, // T is covariant
destination: { readonly length: number; push(item: T): void; } // T is contravariant
): number; So, what I've been suggesting, is to allow use-site variance annotations that construct new types from existing: declare function moveData<T>(
source: out T[], // not the same as readonly T[]
destination: in T[] // not the same as writeonly T[]
): number; The examples with arrays usually are very confusing because they are usually really meant readonly/writeonly. But I wanted to show with simple constructs. |
Just hit the exact case @Jessidhia mentioned in #10717 (comment). Is there a workaround other than |
There's a way to get type safety even in the presence of bivariance. The example in the handbook is this one: enum EventType {
Mouse,
Keyboard,
}
interface Event {
timestamp: number;
}
interface MyMouseEvent extends Event {
x: number;
y: number;
}
interface MyKeyEvent extends Event {
keyCode: number;
}
function listenEvent(eventType: EventType, handler: (n: Event) => void) {
/* ... */
}
// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));
// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) =>
console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y)
);
listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>
console.log(e.x + "," + e.y)) as (e: Event) => void);
// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e)); But we can get what we want with a simple change to the signature of listenEvent: type GenericEvent<E extends EventType> = E extends EventType.Mouse ? MyMouseEvent : MyKeyEvent
function listenEvent<E extends EventType>(eventType: E, handler: (n: GenericEvent<E>) => void): void;
function listenEvent(eventType: EventType, handler: (n: GenericEvent<typeof eventType>) => void): void {
/* ... */
}
// Valid
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y));
// Valid
listenEvent(EventType.Keyboard, (e: MyKeyEvent) => console.log(e.keyCode));
// Invalid
listenEvent(EventType.Mouse, (e: MyKeyEvent) => console.log(e.keyCode));
// Invalid
listenEvent(EventType.Mouse, (e: Event) => console.log(e.x + "," + e.y)); |
- remove currently un-typeable ref (probably related to [1]) - remove `any` typings [1] microsoft/TypeScript#10717
- remove currently un-typeable ref (probably related to [1] or [2]) - remove `any` typings [1] microsoft/TypeScript#10717 [2] microsoft/TypeScript#21759
- default export - rearrange for shorter functions - remove currently un-typeable ref (probably related to [1] or [2]) - remove `any` typings [1] microsoft/TypeScript#10717 [2] microsoft/TypeScript#21759
I've had to build up a type-level DSL around a type-level map to register variance for given data types. I've been especially interested in utilizing Contravariance to build intersections of requirements from smaller pieces fwiw. I think it'd be a great step in the right direction to be able to specify variance per generic explicitly. Any future plans to revisit this issue? |
Seconded @TylorS -- that's actually how I hit this back in ~2013 too, with a type-directed DSL for parser combinators with auto-completion. Back then, unsoundness was a "feature", a design compromise balancing safety vs usability of jQuery and other web frameworks that relied heavily highly overloaded APIs (back before Typescript supported type overloading at all) Unfortunately I'm not working much in the Typescript space anymore. Hopefully someone else on the thread knows if there are other options that might fulfill the same purpose. |
I would like the variance to be untied from generics, but to be a property of types and could be derived from the function code. Look at some analysis here: https://dev.to/ninjin/variance-from-zero-to-the-root-57ek |
I have published a proposal document that makes attempt to address an outstanding issue with type variance, that was brought and discussed at #1394
The work is currently not complete, however the idea is understood and just needs proper wording and documenting. I would like to hear feedback from the TypeScript team and community before I waste too much :).
Please see the proposal here - https://github.com/Igorbek/TypeScript-proposals/tree/covariance/covariance, and below is a summary of the idea
Problem
There's a huge hole in the type system that assignability checking does not respect a contravariant nature of function input parameters:
Currently, TypeScript considers input parameters bivariant.
That's been designed in that way to avoid too strict assignability rules that would make language use much harder. Please see links section for argumentation from TypeScript team.
There're more problematic examples at the original discussion #1394
Proposal summary
Please see proposal document for details.
in
andout
) in generic type argument positionsin
annotates contravariant generic type argumentsout
annotates covariant generic type argumentsin out
andout in
annotate bivariant generic type argumentsin
andout
are internally represented by compiler constructed types (transformation rules are defined in the proposal)Additionally, there're a few optional modifications being proposed:
in
andout
) in generic type parameter positions to instruct compiler check for co/contravariance violations.Details
Within a type definitions each type reference position can be considered as:
So that when a generic type referenced with annotated type argument, a new type constructed from the original by stripping out any variance incompatibilities:
write(x: T): void
is removed whenT
referenced without
read(): T
is reset toread(): {}
whenT
referenced within
prop: T
becomesreadonly prop: T
whenT
referenced without
Examples
Say an interface is defined:
So that, when it's referenced as
A<out T>
or with any other annotations, the following types are actually constructed and used:Links
Call for people
@ahejlsberg
@RyanCavanaugh
@danquirk
@Aleksey-Bykov
@isiahmeadows
The text was updated successfully, but these errors were encountered: