why is type checker considering this as a wrong type and how to deal with that?

Viewed 45

I have a problem with the typescript type checker. I defined the following types and type guard:

type Result = ResultFail | ResultSuccess;

type ResultSuccess = {
  data: unknown;
  success: true
};

type ResultFail = {
  data: Record<string, never>;
  success: false;
}

const isResultFail = (subject: Result): subject is ResultFail =>
  !subject.success

type A =
  | ASuccess
  | AFail;

type ASuccess =
  ABase & {
    result: ResultSuccess;
  };

type AFail =
  ABase & {
    result: ResultFail;
  };

type ABase = {
  x: number;
  y: number;
};

I am trying to write the function that build A. When I am doing it like this it is not working:

const buildA = (result: Result): A => {
  return {
    x: 1,
    y: 1,
    result,
  }
}

But when I am doing it the other way it works:

const buildA = (result: Result): A => {
  if (isResultFail(result)) {
    return {
      result,
      x: 1,
      y: 1,
    }
  }
  return {
      result,
      x: 1,
      y: 1,
    }
}

Can someone explain to me why first version does not work, but the second does. I am really confused about it.

1 Answers

When the compiler compares a type X to see if is assignable to another type Y, it unfortunately cannot spend too much time checking. If they have different apparent structure, the assignment might be rejected even if every valid value of type X is also a valid value of type Y. One such situation where presumably valid assignments get rejected is when you try to assign unions of objects with properties like {a: string} | {a: number} to objects with unions of proprerties like {a: string | number}:

declare let x: { a: string } | { a: number }
declare let y: { a: string | number };
x = y; // error

In order to validate this assignment, the compiler would need to split apart {a: string | number} into {a: string} | {a: number}. While this wouldn't be so bad, the general procedure would be very unwieldy. Imagine the compiler is given {a: string | number | symbol, b: Date | RegExp | string[], c: 1 | 2 | 3 | 4 | 5 | 6} and needs to test against some union of object types. It would need to produce a union of 3×3×6 = 54 members, and similar unions elsewhere would lead to an explosion of types for the compiler to check.

See microsoft/TypeScript#12052 and related issues for more information, and specifically this comment on microsoft/TypeScript#45230 for an authoritative statement about why this can't be done in general.


Before TypeScript 3.5, such assignments always failed. But TypeScript 3.5 introduced support for "smarter" union type checking, as implemented in microsoft/TypeScript#30779. Now, if your union-of-object-types is a discriminated union and some other criteria are met, the assignment can succeed:

declare let x: { a: 0, z: 2 | 3 } | { a: 1, z: 3 | 4 }
declare let y: { a: 0 | 1, z: 3 };
x = y; // okay

This works because the type of x is a discriminated union with a as the discriminant property. The literal types 0 and 1 can be used to determine which member of the union you're in. And so the above assignment works since TypeScript 3.5.

So, hooray.


Unfortunately for you, the union type you have is not a discriminated union. Your would-be discriminant property is not a literal type, but an object type which is itself a discriminated union.

declare let x: { a: { d: 0 }, z: 2 | 3 } | { a: { d: 1 }, z: 3 | 4 }
declare let y: { a: { d: 0 } | { d: 1 }, z: 3 };
x = y; // error

That is, your union type is something like a "nested discriminated union". And TypeScript does not support these. See microsoft/TypeScript#18758 for a feature request asking for such support.

Just to be clear, this is what you're trying to do:

declare let x:
  { result: ResultSuccess, x: number, y: number } |
  { result: ResultFail, x: number, y: number };
declare let y: { result: Result, x: number, y: number };
x = y; // error

And this fails because the result property is not seen as a discriminant of a discriminated union. Oh well.


Your other approach works because you use control flow analysis to narrow what I'm calling y to either the left or the right leg of the union of x, and now there is no union propagation to worry about:

declare let x:
  { result: ResultSuccess, x: number, y: number } |
  { result: ResultFail, x: number, y: number };
declare let y1: { result: ResultSuccess, x: number, y: number };
declare let y2: { result: ResultFail, x: number, y: number };
x = y1; // okay
x = y2; // okay

If you're okay with this approach, and you're willing to sacrifice convenience for type safety, great. Otherwise, since this is just a design limitation of TypeScript, my suggestion here is probably for you to use a type assertion and move on with your life. You know more than the compiler here about the safety of your code, so you can just tell it so:

const buildA = (result: Result) => {
  return {
    x: 1,
    y: 1,
    result,
  } as A; // assert here
}

Playground link to code

Related