Skip to content

Conditional type prevents assignability #48033

Open

Description

@dylhunn

Bug Report

🔎 Search Terms

Index signature assignability, generic index signatures, conditional types

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about conditional types

⏯ 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.

Activity

  1. RyanCavanaugh commented on Feb 26, 2022

    @RyanCavanaugh
    Member

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

  2. dylhunn commented on Feb 26, 2022

    @dylhunn
    Author

    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!

  3. dylhunn commented on Feb 26, 2022

    @dylhunn
    Author

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

  4. RyanCavanaugh commented on Feb 28, 2022

    @RyanCavanaugh
    Member

    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.

  5. jihndai commented on Mar 2, 2022

    @jihndai
    Contributor

    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;
  6. dylhunn commented on Mar 31, 2022

    @dylhunn
    Author

    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)]);
    }
    
  7. yseymour commented on Aug 2, 2022

    @yseymour

    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"
  8. RyanCavanaugh commented on Aug 2, 2022

    @RyanCavanaugh
    Member

    Yudhi Seymour (@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;
  9. yseymour commented on Aug 3, 2022

    @yseymour

    Ryan Cavanaugh (@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.

  10. RyanCavanaugh commented on Aug 5, 2022

    @RyanCavanaugh
    Member

    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.

  11. mkantor commented on Nov 13, 2024

    @mkantor
    Contributor

    Here's another example of this behavior which caught me by surprise:

    type Conditional<A> = true extends A ? unknown : never
    
    type Foo<A> = { readonly x: Conditional<A> }
    type Bar<A> = { readonly x: Conditional<A> }
    
    declare const fooFalse: Foo<false>
    declare const barFalse: Bar<false>
    
    const fooFromFoo: Foo<unknown> = fooFalse // error
    const barFromBar: Bar<unknown> = barFalse // error
    
    const fooFromBar: Foo<unknown> = barFalse // no error
    const barFromFoo: Bar<unknown> = fooFalse // no error

    EDIT: Mapped/index-signatured types behave similarly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions