Why is this object's type resolving to one side of a type union?

Viewed 109

See this playground.

Why on line 18 has obj already resolved as Foo, which causes it to then be of type never on line nineteen? It seems like at that point in the code it should very much still be Foo | Bar.

3 Answers

Because Math.random() can be known only in runtime, TS is unable to make decision about a type.

So I think TS just don't takes into account Bar type at all. That's why you have never.

Further more, TS plays bad with spread operator.

But I'm not convinced yet :)

I was wrong in my comment. TS in operator behavied like a typeguard, but since TS 3.8.3 it is not typeguard anymore:

// TS 3.7.5
interface Foo {
    bar: string;
}

type Bar = Foo & {
    baz: string;
}

const produceBoolean = () => Math.random() > 0.5;

const obj:Foo|Bar = {
    bar: 'baz',
    ...(produceBoolean() && {
        baz: 'bar'
    })
}

if ('baz' in obj) {
    console.log(obj) // Bar
}

if ('bar' in obj) {
    console.log(obj) // Foo | Bar
}

Playground

//TS 3.8.3

interface Foo {
    bar: string;
}

type Bar = Foo & {
    baz: string;
}

const produceBoolean = () => Math.random() > 0.5;

const obj:Foo|Bar = {
    bar: 'baz',
    ...(produceBoolean() && {
        baz: 'bar'
    })
}

if ('baz' in obj) {
    console.log(obj) // never
}

if ('bar' in obj) {
    console.log(obj) // Foo
}

Playground

I don't understand why they made such a decision.

Further more, if you remove explicit typing:

interface Foo {
    bar: string;
}

type Bar = Foo & {
    baz: string;
}

const produceBoolean = () => Math.random() > 0.5;

const obj = {
    bar: 'baz',
    ...(produceBoolean() && {
        baz: 'bar'
    })
}

if ('baz' in obj) {
    console.log(obj) // baz is still optional
}

baz property will be still optional.

I think the reason is the same as Object.keys() always returns string[] instead of expected keyof T - because of mutability

Feel free to criticize me

Your type Bar is defined as a subtype of Foo, so the union Foo | Bar is actually just equivalent to Foo. By analogy, if outside your house there is either a tree or an oak tree, then more simply we can just say there is a tree.

After seeing that this behavior was different a few versions of Typescript ago (3.7.5), and deciding it seemed like a bug, I opened an issue on their issue tracker here: https://github.com/microsoft/TypeScript/issues/42771

They have just labeled it as a bug as well, so for now I guess I'll mark this as answered with the answer being, "It's not working as intended."

Related