Open dylhunn opened 2 years ago
Likely we're considering a type extends T
to mark Foo<T>
as invariant in T
Likely we're considering a type extends T to mark Foo
as invariant in T
Is this on a roadmap or existing issue? I'd like to know whether to wait for this fix, or design around it. Thanks!
(More context: we'd be using this as part of the solution for this longstanding Angular issue.)
It's very possible a straightforward fix here (if one is even possible) could have side effects that result in the fix being rejected. I'd recommend designing around it if possible.
After walking through the compiler a bit, it does seem to be marking type extends T
's case as invariant like you said, while T extends type
's case gets marked as bivariant.
Is the expected behavior for type extends T
to be flagged as bivariant like T extends type
? Although it might satisfy the above test case, I think that it would probably lead to a weird behavior similar to how bivariance currently interacts with T extends type
, for example:
declare class A { a: string; }
declare class B extends A { b: string; }
declare class Foo<T> { foo: T extends B ? number : boolean }
declare let fooA: Foo<A>;
declare let tempA: Foo<A>;
declare let fooB: Foo<B>;
fooA.foo; // boolean
fooB.foo; // number
tempA = fooA;
fooA = fooB;
fooB = tempA;
fooA.foo; // incorrectly boolean
fooB.foo; // incorrectly number
Or should we try something else, like somehow applying a structural comparison between the classes when we encounter those kinds of conditional types with generics? For example, below I use a duplicate of class Foo<T>
to cause the compiler to perform a structural comparison instead of checking type parameters variance:
declare class A { a: string; }
declare class B extends A { b: string; }
declare class Foo<T> { foo: B extends T ? number : boolean }
declare class Boo<T> { foo: B extends T ? number : boolean }
declare let fooA: Foo<A>;
declare let tempA: Foo<A>;
declare let fooB: Boo<B>;
tempA = fooA;
// both assignment works because fooA.foo and fooB.foo are both number
// even though the type parameters aren't bivariant
fooA = fooB;
fooB = tempA;
Here's a more robust and extremely surprising example of what can happen as a result of this invariance, in particular as it interacts with boolean
widening. Playground link.
Code:
class FormArray<T extends FormControl<any>> {
constructor(public readonly controls: Array<T>) {}
// When T is on the RHS of a conditional type, FormArray<T> becomes invariant in T.
// https://github.com/microsoft/TypeScript/issues/48033
foo!: string extends T ? true : false;
}
class FormControl<T> {
constructor(public readonly value: T) {}
}
function test() {
// Notice that TypeScript infers this as `FormControl<boolean>` -- there are some
// (likely special-cased) rules for inferring `boolean` instead of the narrow literal type `true`
const fc1 = new FormControl(true);
// Here, explicitly specifying the boolean on the LHS blocks the special casing above,
// causing the RHS constructor to be inferred as `FormControl<true>`.
const fc2: FormControl<boolean> = new FormControl(true);
// This infers as `FormArray<FormControl<boolean>>`, as above.
const fa1 = new FormArray([new FormControl(true)]);
// Error! Again, providing an explicit type causes the RHS control to be inferred
// as `FormControl<true>` instead of `FormControl<boolean>`. Because `FormArray<T>`
// is invariant in T, `FormArray<FormControl<true>>` is not assignable to `FormArray<FormControl<boolean>>`.
const fa2: FormArray<FormControl<boolean>> = new FormArray([new FormControl(true)]);
}
This also affects mapped types:
interface M {
A: number;
B: number;
}
declare class Foo<T extends keyof M> {
bar: M[T]
}
declare let foo1: Foo<"A">;
let foo2: Foo<"B"> = foo1; // "A" is not assignable to "B"
@yseymour that's a correct error. When M[T]
is anything other than a roundabout way of writing number
, the assignment is unsafe
interface M {
A: number;
B: string; // <- changed
}
declare class Foo<T extends keyof M> {
bar: M[T]
}
// Legal initialization
let foo1: Foo<"A"> = { bar: 32 }
// Purported legal assignment
let foo2: Foo<"B"> = foo1;
// Unsound - number inhabits string binding
const m: string = foo2.bar;
@RyanCavanaugh, agreed, I'd expect an assignability error between the two Foo
s in your example. But in mine, Foo<"A">["bar"]
and Foo<"B">["bar"]
are both number
, so intuitively I'd expect the Foo
s to be fungible. Is it perhaps the case that checker doesn't look through the type argument to do a structural check on the individual members?
Maybe this is a different issue to the OP.
Types are not evaluated by iterating over each possible thing the type could be inhabited by, since doing things that way is combinatorially explosive. Things are generally either true for a category, or not, and an operation like this isn't valid at a category level.
Bug Report
🔎 Search Terms
Index signature assignability, generic index signatures, conditional types
🕗 Version & Regression Information
⏯ Playground Link
Playground link with relevant code
💻 Code
🙁 Actual behavior
When a conditional type is present in
Foo
, I sometimes cannot assignFoo<{a: any}>
toFoo<[x: string]: any>
, depending on the condition.🙂 Expected behavior
Regardless of the conditional type, I should be able to assign
Foo
with named properties toFoo
with an index signature.