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

Viewed 1035

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

4 Answers

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

needs to be

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

then it compiles.

Cheers, Mike

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

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

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.

Related