Skip to content

Unexpected literal union type widening in generic type inference #32596

Closed

Description

@ulrichb

TypeScript Version: 3.5.1

Search Terms: type widening generic union inference

Code

function example() {

    type SomeUnion = "A" | "B" | "C";

    function fun<T extends SomeUnion>(x: T, y: T) { }

    fun("A", "A");  // no error

    fun("A", "B");  // !!! no error because widening to "A" | "B"
    fun("B", "A");  // !!! no error because widening to "A" | "B"
}


function counterexample() {

    type SomeUnion = string | number | boolean;

    function fun<T extends SomeUnion>(x: T, y: T) { }

    fun("str", "other");  // no error
    fun("str", 42);       // error as expected
    fun(42, "str");       // error as expected
}

Expected behavior:
Compile errors in the two "!!!" marked lines.

Actual behavior:
No compile error, instead the type inference inferred "A" | "B" for T.

That's pretty unexpected and IMO a bug, because for literal type parameters it makes no sense to infer any union, it obviously can only be one value (therefor the type widening should stop when seeing a parameter which is directly the generic literal union type).

Also it's asymmetric with non literal union types (see counterexample).

Playground Link: Here

Activity

  1. ulrichb commented on Jul 28, 2019

    @ulrichb
    Author

    For reference this is my original code (simplified version of my real scenario):

    function extended_example() {
    
        type SomeUnion = "A" | "B" | "C";
    
        interface SomeInterface<T extends SomeUnion> { prop: T }
    
        const a: SomeInterface<"A"> = { prop: "A" }
        const b: SomeInterface<"B"> = { prop: "B" }
    
        function fun<T extends SomeUnion>(x: T, y: SomeInterface<T>) { }
    
        fun("A", a); // no error
    
        fun("A", b); // !!! no error because widening to "A" | "B"
        fun("B", a); // !!! no error because widening to "A" | "B"
    }

    ... here I can pass a wrong SomeInterface<T> next to T which is (again pretty unexpected and) not very type safe :/

  2. AnyhowStep commented on Jul 28, 2019

    @AnyhowStep
    Contributor

    Somewhat unrelated. But what do you think of the following?

    const interfaceB: SomeInterface<"B"> = { prop: "B" }
    function fun<T extends SomeUnion>(x: T, y: SomeInterface<T>) { }
    
    declare const aOrB : "A"|"B";
    //Could possibly be "A", interfaceB
    fun(aOrB, interfaceB);
  3. ulrichb commented on Jul 29, 2019

    @ulrichb
    Author

    AnyhowStep

    Interesting. I thinks here it's okay that no error happens because there is no "out of bounds" type widening involved like my ("A" literal typed argument widened to "A" | "B" in my example).

    Also it is symmetric with non-literals ...

    type SomeUnion = string | number | boolean;
    function fun<T extends SomeUnion>(x: T, y: T) { }
    declare const strOrNum: string | number;
    fun(strOrNum, 42); // no error as expected

    But this brings me to the following example which should error again (it's generalization of the original example):

    type SomeUnion = "A" | "B" | "C";
    interface SomeInterface<T extends SomeUnion> { prop: T }
    function fun<T extends SomeUnion>(x: T, y: SomeInterface<T>) { }
    
    declare const aOrB : "A" | "B";
    const interfaceC: SomeInterface<"C"> = { prop: "C" }
    fun(aOrB, interfaceC); // !!! no error

    which again is asymmetric with non-literals:

    type SomeUnion = string | number | boolean;
    function fun<T extends SomeUnion>(x: T, y: T) { }
    declare const strOrNum: string | number;
    fun(strOrNum, true); // error as expected
  4. AnyhowStep commented on Jul 29, 2019

    @AnyhowStep
    Contributor

    I'm not on my computer but what happens when you try this,

    function fun (...args : ["A", Some<"A">]|["B", Some<"B">])

    If the above works as you expected, how about,

    type Blah<T> = T extends any ? [T, Some<T>] : never
    
    function fun (...args : Blah<SomeUnion>)
    
    function fun2<ArgsT extends Blah<SomeUnion>> (...args : ArgsT)
  5. RyanCavanaugh commented on Jul 29, 2019

    @RyanCavanaugh
    Member

    It's assumed that a union of two literal types from the same primitive family are an OK inference, but not from two different families.

    Any inference that matches the constraint and is a supertype of all candidates is legal and your function implementation should be assuming that this is possible - in the worst case, a caller of your function could write

    fun<"A" | "B">("A", "B");

    See also #14829

    You could write this instead:

    function extended_example() {
    
        type SomeUnion = "A" | "B" | "C";
    
        interface SomeInterface<T extends SomeUnion> { prop: T }
    
        const a: SomeInterface<"A"> = { prop: "A" }
        const b: SomeInterface<"B"> = { prop: "B" }
    
        function fun<T extends SomeInterface<SomeUnion>>(x: T extends SomeInterface<infer U> ? U : never, y: T) { }
    
        fun("A", a); // no error
    
        fun("A", b); // errors
        fun("B", a); // errors
    }
  6. ulrichb commented on Jul 29, 2019

    @ulrichb
    Author

    Okay. Thanks for info and sharing the workaround with the conditional type.

    ... and +1 for NoInfer<T> :)

  7. locked as resolved and limited conversation to collaborators on Oct 21, 2025
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

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions