Skip to content

Wrong unification of union when the alternative types have some intersection. #11403

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
sledorze opened this issue Oct 5, 2016 · 7 comments
Closed
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@sledorze
Copy link

sledorze commented Oct 5, 2016

TypeScript Version: 2.0.3

Code

function foo<A, B>(c:boolean, a:A, ab:A & B) { // foo return type is inferred to A instead of A | (A & B)
    return c ? a : ab; 
}

Expected behavior:

foo return type is inferred to A | (A & B)

Actual behavior:

foo return type is inferred to A

@yortus
Copy link
Contributor

yortus commented Oct 6, 2016

This is a case of subtype reduction. A & B is always a subtype of A regardless of the A and B types. So it is valid to simplify A | (A & B) to just A, and the compiler does so.

@sledorze
Copy link
Author

sledorze commented Oct 6, 2016

@yortus ok, I should have restated the title.

Yes, it is valid to simplify ( A | (A & B)) to A in the sense it won't accept invalid program, however it is rejecting some valid ones (which is less mandatory, I agree).

It is correct but I find it non desirable as it looses some information when you want to use the case A & B.

I've been beaten by that when porting a Js code that optionally set some properties on an object and later check for their existence.. I was not able to change the code logic (not owning it) so it became a blocker and resulted into some 'any' jungling which make me loose type safety.. :(

In short: this subtype reduction loose some information resulting into rejecting a valid program.

I understand it is required to keep types simples and help with unification space / time.
I'm however wondering if this feature could be use behind a flag.. ?

@yortus
Copy link
Contributor

yortus commented Oct 6, 2016

In short: this subtype reduction loose some information resulting into rejecting a valid program.

Yes TypeScript does reject some valid programs due to a combination of subtype reduction and the way it does type narrowing. Another example is #10471. It is indeed a problem sometimes.

Could you provide a code snippet showing the kind of valid program rejection you are encountering? I'm sure it would be useful feedback for the TS team.

@sledorze
Copy link
Author

sledorze commented Oct 6, 2016

I've narrowed it down to that:

    function foo(name:string, age:number, userId?:number)  { // foo return type is { name: string, age: number }
        const defaultUser = {
            name: name,
            age: age
        };
        return userId ? Object.assign({ userId: userId }, defaultUser) : defaultUser;
    }

foo should be of type equivalent to:
{ name: string, age: number } | { name: string, age: number, userId: number } so that the value could be inspected later on.

Note that it should not be
{ name: string, age: number, userId: number | undefined }
Which means we're taking advantage of control flow to discard having a userId with an undefined value.

Currently with typescript, this is not possible AFAIK without forcing the type system.

@yortus
Copy link
Contributor

yortus commented Oct 6, 2016

OK, supposing the foo function did return the type { name: string, age: number } | { name: string, age: number, userId: number }, how would you inspect it later on? x.userId would be invalid for the union. I think you would need a user-defined type guard to narrow the union first, something like the following:

let record: { name: string, age: number } | { name: string, age: number, userId: number } = foo('bob', 42);

function hasUserId<T extends {userId?}>(x: T): x is T & { userId: number; } {
    return x && typeof x.userId === 'number';
}

if (hasUserId(record)) {
    record.name // OK
    record.age // OK
    record.userId // OK
}
else {
    record.name // OK
    record.age // OK
    record.userId // ERROR
}

...and that type guard works just fine with the current { name: string, age: number } return type too, so in the end it doesn't seem to create a greater problem that you already would have in accessing the userId member from the union.

@sledorze
Copy link
Author

sledorze commented Oct 6, 2016

Sorry but this example does not compile (maybe due to my compiler options).

A working implementation of the type guard in my context is:

function hasUserId<T>(x: T & { userId?: number }): x is T & { userId: number; } {
    return x && x.userId !== null && typeof x.userId === 'number';
}

But I find that to be a lot of ceremony where one would just like to rely on cheap and lean workflow type inference.

Also, the type is not guiding the developper into knowing what value would the function returns, not helping with type directed development.

This issue is all about tradeoffs.
I am wondering what is inside the balance?

@RyanCavanaugh RyanCavanaugh added Needs Investigation This issue needs a team member to investigate its status. and removed Needs Investigation This issue needs a team member to investigate its status. labels May 24, 2017
@RyanCavanaugh RyanCavanaugh added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Needs Investigation This issue needs a team member to investigate its status. labels Sep 20, 2019
@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

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

4 participants