Typescript: Discriminated union based on type

Viewed 43

Why is the following not possible for TS ? Why cant I use a type as discriminant ?

export interface A1 {
  plop: number;
}
export interface B1 {
  hop: number;
}

export interface A {
  foo: number;
  bar: string;
  inner: A1;
}

export interface B {
  foo: number;
  bar: string;
  inner: B1;
}

export type AorB = A | B;

function test(): AorB {
  let inner: A1 | B1;

  if (Math.random()) {
    inner = {plop: 4};
  } else {
    inner = {hop: 43};
  }

  return {
    foo: 42,
    bar: 'plop',
    inner
  };
}

The TS compiler tells me :

  Type '{ foo: number; bar: string; inner: A1 | B1; }' is not assignable to type 'B'.
    Types of property 'inner' are incompatible.
      Type 'A1 | B1' is not assignable to type 'B1'.
        Property 'hop' is missing in type 'A1' but required in type 'B1'.
1 Answers

Consider for a moment the expansion of your types. The type AorB represents the union:

{
  foo: number;
  bar: string;
  inner: A1;
} | {
  foo: number;
  bar: string;
  inner: B1;
}

Noting that nowhere in this does the type A1 | B1 appear. This is to say that the type AorB is expecting to have an object where the inner property is known and fixed as either A1 or B1.

But wait? Since the properties of the enclosing object are the same (namely foo and bar), shouldn't the type above be equivalent to:

{
  foo: number;
  bar: string;
  inner: A1 | B1;
}

Logically, that makes sense, you can distribute the inner union over the enclosing object type and see that you would get the same union of objects that you have for AorB. And in fact it looks like this is a known issue, but currently TypeScript isn't able to make this inference.


To fix this, I see a few options. First, you can just use a single interface for AorB where the inner property is given the type A1 | B1:

interface AorB {
  foo: number;
  bar: string;
  inner: A1 | B1;
}

As another option, you can modify the way in which you construct the returned object in your test function to clearly indicate to typescript that the resulting object has a fixed inner property:

function test2(): AorB {
  let outer = {
    foo: 42,
    bar: 'plop',
  };

  if (Math.random()) {
    // Clearly has type A
    return {
      ...outer,
      inner: {plop: 4}
    }
  } else {
    // Clearly has type B
    return {
      ...outer,
      inner: {hop: 43}
    }
  }
}

This way, it is obvious to the TS compiler that one branch returns type A and the other type B, which matches cleanly to the return type of AorB.

And finally, since we humans can see that the two types are in fact equivalent, you can always just ignore the error:

function test(): AorB {
  let inner: A1 | B1;

  if (Math.random()) {
    inner = {plop: 4};
  } else {
    inner = {hop: 43};
  }

  // @ts-ignore: This is equivalent to the type AorB after distributing the inner union...
  return {
    foo: 42,
    bar: 'plop',
    inner
  };
}
Related