Error when assigning compatible type to a discriminated union type

Viewed 78

I have a reduced example case. I create a discriminated union AOrB, and a (to my mind) compatible type C:

interface A {
  kind: 'a';
}

interface B {
  kind: 'b';
}

type Kind = 'a' | 'b';

interface C {
  kind: Kind;
}

interface D {
  kind: 'b';
}

type AOrB = A | B;

function test(input: C): AOrB {
  return input;
}

function test2(input: D): AOrB {
  return input;
}

Fails with

Type 'C' is not assignable to type 'AOrB'.
  Type 'C' is not assignable to type 'B'.
    Types of property 'kind' are incompatible.
      Type 'Kind' is not assignable to type '"b"'.
        Type '"a"' is not assignable to type '"b"'.

This makes it seem like I can't assign an object to a variable of type AOrB unless it is known ahead of time whether it is, in fact, and A or a B. It isn't just that they need the same exact type, as test2 compiles fine.

Can anyone explain what is going on?

2 Answers

UPDATE: 2019-05-30 with the release of TypeScript 3.5 this should be addressed by smarter union type checking. The following applies to 3.4 and below:


This issue has been reported before and it's essentially a compiler limitation. While you and I understand that {a: X} | {a: Y} is equivalent to {a: X | Y}, the compiler does not.

In general you can't combine unions of objects into objects of unions unless the objects differ only in the type of one property (or rather that the differing properties represent all possible distributions of the unions). For example, you can't collapse {a: X, b: Z} | {a: Y, b: W} to something like {a: X | Y, b: Z | W}... the type {a: X, b: W} is assignable to the latter but not the former.

Imagine writing the compiler to try to make such reductions; for the vast majority of cases it would spend precious processor time examining union types only to find that it cannot combine them. The relatively rare cases like yours where the reduction is possible are probably just not worth it.

Even in your case, I'm skeptical; are you really trying to make a discriminated union where only the discriminant property is different? That's a pathological case. The intended use case for discriminated unions is to take a set of different types and add a single property to let you know which type a value is. As soon as you start adding other properties which make the discriminated types truly different, you would have to abandon the idea of collapsing the union.


So, assuming you really need the non-discriminated discriminated union above, what can you do? The easiest way is to just tell the compiler you know what you're doing by using a type assertion:

function test(input: C): AOrB {
  return input as AOrB; // you know best
}

This now compiles, although it's not really safe:

function test(input: C): AOrB {
  return (Math.random() < 0.5 ? input : "whoopsie") as AOrB; // also compiles
}

A safer yet more complicated solution is to walk the compiler through the different possibilities:

function test(input: C): AOrB {      
  return input.kind === 'a' ? {kind: input.kind} : {kind: input.kind};
}

Either of those or something else you come up with should work.

Okay, hope that helps; good luck!

So let's get through this step by step. Let's remove A and B as interfaces and Kind as a separate type and replace all occurrences of those interfaces with their exact values:

interface C {
  kind: 'a' | 'b';
}

interface D {
  kind: 'b';
}

type AOrB = { kind: 'a' } | { kind: 'b' }

function test(input: C): AOrB {
  return input;
}

function test2(input: D): AOrB {
  return input;
}

Now, in a similar manner, let's get rid of C, D and AOrB:

function test(input: { kind: 'a' | 'b' }): { kind: 'a' } | { kind: 'b' } {
  return input;
}

function test2(input: { kind: 'b' }): { kind: 'a' } | { kind: 'b' } {
  return input;
}

Now, as you can see in test2 the function returns { kind: 'a' } | { kind: 'b' }. input is of type { kind: 'b' } so naturally it matches that definition.

In test1 the function returns { kind: 'a' } | { kind: 'b' }, but the input is of type { kind: 'a' | 'b' }. Although it is tempting to say that those definitions are matching, an object which contains a single property of type 'a' or type 'b' is not assignable to either an object with a single property of type 'a' or an object with a single property of type 'b'. Type { kind: 'a' | 'b' } allows you to do something like this:

let value: { kind: 'a' | 'b' } = {
    kind: 'a'
};
value.kind = 'a';
value.kind = 'b';

Now, replace { kind: 'a' | 'b' } with { kind: 'a' } | { kind: 'b' }:

let value: { kind: 'a' } | { kind: 'b' } = {
    kind: 'a'
};
value.kind = 'a';
value.kind = 'b';

The value was initialised with a value of type { kind: 'a' } so the compiler assumes that that is the type of a value that this variable contains now. In that case, assigning b to the kind property will be considered illegal.

Related