microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.64k stars 12.44k forks source link

Conditional type prevents assignability #48033

Open dylhunn opened 2 years ago

dylhunn commented 2 years ago

Bug Report

🔎 Search Terms

Index signature assignability, generic index signatures, conditional types

🕗 Version & Regression Information

⏯ Playground Link

Playground link with relevant code

💻 Code

// Use of the conditional type `string extends T` prevents assignability.
// However, the conditional type `T extends string` is accepted.

declare class Foo<T>  {
    bar: string extends T ? number : boolean;
}

declare let indexable: Foo<{[key: string]: boolean}>;
declare let namedProp: Foo<{baz: boolean}>;

indexable = namedProp;

🙁 Actual behavior

When a conditional type is present in Foo, I sometimes cannot assign Foo<{a: any}> to Foo<[x: string]: any>, depending on the condition.

🙂 Expected behavior

Regardless of the conditional type, I should be able to assign Foo with named properties to Foo with an index signature.

RyanCavanaugh commented 2 years ago

Likely we're considering a type extends T to mark Foo<T> as invariant in T

dylhunn commented 2 years ago

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!

dylhunn commented 2 years ago

(More context: we'd be using this as part of the solution for this longstanding Angular issue.)

RyanCavanaugh commented 2 years ago

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.

jihndai commented 2 years ago

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;
dylhunn commented 2 years ago

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)]);
}
yseymour commented 2 years ago

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"
RyanCavanaugh commented 2 years ago

@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;
yseymour commented 2 years ago

@RyanCavanaugh, agreed, I'd expect an assignability error between the two Foos in your example. But in mine, Foo<"A">["bar"] and Foo<"B">["bar"] are both number, so intuitively I'd expect the Foos 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.

RyanCavanaugh commented 2 years ago

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.