Mixing union types, generics and conditional types causes unexpected "Type is not assignable to type" error
Asked Answered
R

4

9

I've hit a problem with type-inference specifically when conditional-types are used within union types.

There may be a shorter way to demonstrate this issue, but I could not find one...

See the problem in action at this playground link.

Consider the following Result<T>, a union-type used to indicate the success or failure of an operation (with an optionally attached value, of type T). For the success-case, I have used the conditional type SuccessResult<T>, which resolves to either OKResult or ValueResult<T> (depending on whether the result should also carry an attached value):

type Result<T = undefined> = SuccessResult<T> | ErrorResult;

interface OKResult {
    type: 'OK';
}
interface ValueResult<T> {
    type: 'OK';
    value: T;
}
interface ErrorResult {
    type: 'Error';
    error: any;
}
type SuccessResult<T = undefined> = T extends undefined ? OKResult : ValueResult<T>;

function isSuccess<T>(result: Result<T>): result is SuccessResult<T> {
    return result.type === 'OK';
}

Let's use it with a simple union type:

type C1 = "A1" | "B1";
function makeC1(): C1 { return "A1" }
const c1: C1 = makeC1();
const c1Result: Result<C1> = { type: "OK", value: c1 }; // ALL IS GOOD

Now, instead of the simple union type C1, which is just "A1" | "B1", let use a union type of complex values, C2, in exactly the same way:

type A2 = {
    type: 'A2';
}
type B2 = {
    type: 'B2';
}
type C2 = A2 | B2;
function makeC2(): C2 { return { type: "A2" } }
const c2: C2 = makeC2();
const c2Result: Result<C2> = { type: "OK", value: c2 }; // OH DEAR!

This results in an error:

Type 'C2' is not assignable to type 'B2'.

Type 'A2' is not assignable to type 'B2'.

Types of property 'type' are incompatible.

Type '"A2"' is not assignable to type '"B2"'.

If I remove conditional typing from the equation and define my Result<T> to use ValueResult<T> instead of SuccessResult<T>:

type Result<T = undefined> = ValueResult<T> | ErrorResult;

...everything works again, but I lose the ability to signal valueless success. This would be a sad fallback if I can't get the optional typing to work in this case.

Where did I go wrong? How can I use SuccessResult<T> in the Result<T> union, where T itself is a complex union type?

Playground link

Rameriz answered 19/9, 2020 at 23:37 Comment(0)
G
2

This happens because C2 is A2 | B2 type while Result<C2> is {type: 'OK'; value: A2} | {type: 'OK'; value: B2} | ErrorResult.

So the problem comes down to the equivalency of {type: 'OK'; value: A2 | B2} and {type: 'OK'; value: A2} | {type: 'OK'; value: B2}. This seems equivalent until you add more properties into such object. TypeScript doesn't treat this as a special case and considers general case (imagine more properties) that isn't equvivalent.

Let's consider another example: { x: "foo" | "bar" } and { x: "foo" } | { x: "bar" }.

For example, it wouldn't be correct to consider { x: "foo" | "bar", y: string | number } to be equivalent to { x: "foo", y: string } | { x: "bar", y: number } because the first form allows all four combinations whereas the second form only allows two specific ones.

Source: https://github.com/microsoft/TypeScript/issues/12052#issuecomment-258653766

Golub answered 29/9, 2020 at 6:37 Comment(1)
This answer made the most sense to me, so I award you the bounty. It is a somewhat arcane problem that I eventually re-wrote, modelling the OKResult instead as ValueResult<undefined>, but hopefully this will be useful to someone else.Rameriz
G
1

type Result<T = undefined> = SuccessResult<T> | ErrorResult;

needs to be

type Result<T = undefined> = SuccessResult<T> | ErrorResult | ValueResult<T>;

then it compiles.

Cheers, Mike

Gonsalez answered 22/9, 2020 at 10:51 Comment(2)
type Result<T = undefined> = OKResult | ValueResult<T> | ErrorResult is probably a better fit here. Yes, I had observed this already... However, this subtly changes the semantics of Result<T>, meaning, for instance, that we can assign ValueResult<undefined> to Result<T> when T extends undefined. Not quite right.Rameriz
It does make me wonder whether OKResult should really just be ValueResult<undefined> though. It would certainly make the model simpler. However, I'm hoping to gain insight into the behaviour of conditional types, because at the moment, I'm missing something.Rameriz
N
1

Unfortunately specifying the return value of a function is not enough. You need to explicitly return the type. Then it compiles.

This doesn't work

function makeC2(): C2 {
    return {
        type: "A2"
    };
};

This works

function makeC2() {
    const x: C2 = {
        type: "A2"
    };
    return x;
};

Playground link

Nabal answered 26/9, 2020 at 12:48 Comment(1)
If you can tell me why this matters from the "view" outside the component, the points are yours.Rameriz
G
1

As already mentioned by in some of the answers, problem is that you have A2 and B2 which are two different types of objects, and although they are same in your case (both have property type), you could easily append other properties to one, but not the other, and in that case Result<C2> would not be able to differ if the object behind that type is actually correct since A2 would not be assignable to B2 and vice-versa.

What you could do (if your resulting types have same properties) is create some kind of base type where type is actually a generic union, which would resolve to one of the specific values like so:

type BaseType<T extends 'A2' | 'B2'> = {
    type: T;
}

type A2 = BaseType<'A2'>
type B2 = BaseType<'B2'>
type C2 = A2 | B2;

function makeC2(): C2 { return { type: 'A2' } }
const c2 = makeC2();
const c2Result: Result<BaseType<'A2' | 'B2'>> = { type: "OK", value: c2 };

or maybe even simpler version

type BaseType = {
    type: 'A2' | 'B2';
}

function makeC2(): BaseType { return { type: 'A2' } }
const c2 = makeC2();
const c2Result: Result<BaseType> = { type: "OK", value: c2 };

It all depends on your case and whether you can share your types like such, or they are specific.

Gamete answered 29/9, 2020 at 8:20 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.