Skip to content

Unexpected literal union type widening in generic type inference #32596

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

Closed
ulrichb opened this issue Jul 28, 2019 · 6 comments
Closed

Unexpected literal union type widening in generic type inference #32596

ulrichb opened this issue Jul 28, 2019 · 6 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@ulrichb
Copy link

ulrichb commented Jul 28, 2019

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

@ulrichb
Copy link
Author

ulrichb commented Jul 28, 2019

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 :/

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Jul 28, 2019

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);

@ulrichb
Copy link
Author

ulrichb commented Jul 29, 2019

@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

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Jul 29, 2019

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)

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jul 29, 2019
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jul 29, 2019

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
}

@ulrichb
Copy link
Author

ulrichb commented Jul 29, 2019

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

3 participants