Elliot Nelson
Elliot Nelson

Reputation: 11557

How to avoid collapsing a wrapped union type

It appears to me that Typescript is collapsing a wrapped union type (undesired) when it happens to know the initial value. I'm not sure that this is a bug, but I'm wondering if there's a way to avoid it.

An example:

type Prime = 3|5|7;
type OrDonuts<T> = T | 'donuts';

function compare<T>(a: T, b: OrDonuts<T>) {
    return a == b;
}

let value: Prime;
compare(value, 3);
// OK!

value = 5;
compare(value, 3);
// TS2345: Argument of type '5' is not assignable to parameter of type 'OrDonuts<3>'

In order to get around this error, I have to explicitly uncollapse by saying things like value = 5 as Prime;.

Is this a bug, expected behavior, am I just doing it wrong?

(node: 10.15, typescript: 3.5.1)

Upvotes: 2

Views: 1204

Answers (2)

jcalz
jcalz

Reputation: 328738

The TypeScript type checker performs control flow type analysis, meaning that it makes an effort to determine what happens to the values inside variables at runtime and will narrow the types of these variables to match. One of the specific ways this happens is if you have a variable or property of a union type, and the compiler sees you assign a more specifically-typed value to it, it will narrow the type of the variable to that more specific type, at least until you assign some different value to the variable.

This is very often desirable, since it supports use cases like this:

let x: number | string = Math.random() < 0.5 ? "hello" : "goodbye"; // x is string now
if (x.length < 6) { // no error here
  x = 0; // the widest x can be is string | number, so this assignment is fine
}

Note that even though you have annotated that x is a number | string, the compiler understands that it will definitely be a string after the initial assignment. So it does not complain when you check x.length (as it would if x could possibly be a number). This is such a useful behavior that to disable it would lead to lots of real-world TypeScript code breakage.

Unfortunately, it's also responsible for the behavior you're seeing here. After assigning 5 to value, the compiler sees value as containing a variable of the narrowed type 5. Not Prime. You can always widen value to Prime, but the compiler will not do that automatically. It thinks it is helping you by warning you that you are calling compare(5 as 5, 3) which is forbidden.

In this case, the only way to override this behavior is with a type assertion, as you've seen. You can do this assertion either on the initial assignment, or inside the call to compare():

let value2: Prime = 5 as Prime
compare(value2, 3); // okay

let value3: Prime = 5;
compare(value3 as Prime, 3); // okay

Or, you could manually specify the generic type T in your call to compare(), which also works:

let value4: Prime = 5;
compare<Prime>(value4, 3); // okay

Any of those options are available to you.

The most canonical source of documentation I can find for this is Microsoft/TypeScript#8513, and specifically this comment.

Okay, hope that helps; good luck!

Link to code

Upvotes: 3

Murat Karag&#246;z
Murat Karag&#246;z

Reputation: 37604

It is the intended behavior of Union Types. By declaring

type Prime = 3|5|7; // it is either 3 OR 5 OR 7 but never all of them at the the same time

you tell the ts-compiler that Prime is one of the values. Now you assign 5 to the typed field value.

value = 5; // type Prime is now 5
compare(value, 3); // <T> is inferred by the ts-compiler as 5

In order to fix this you have to use value:Prime or type assert as you did.


Without type inference you would be able to pass value e.g.

value = 5;
compare<2>(value, 3); // Argument of type '5' is not assignable to parameter of type '2'.

or if you would satisfy the compare function parameter in a more generic way like

let test = 5;
compare(test, 3); // <T> is now number which 5 and 3 are.

Upvotes: 1

Related Questions