1

I'm trying to check if a value of a union type is one of the union'd types.

In the code below, the first three approaches (1-3) seem to not be the correct one.

The checking for a specific property approach (4) seems to work, although it's unclear which type it has refined into.

  1. Into which type it has refined in (4) (A or B)?
  2. Why either a number or string can't be assigned to a1.a in (5) and (6)?
  3. Which would be the correct way to refine to one of the types in a union, where the type is not a primitive (i.e. is a type alias)?
type A = {
  a: number
}

type B = {
  a: string
}

const a1: A | B = {a: 1}

if (typeof(a1) === 'A') {}    // (1) Cannot compare the result of `typeof` to string literal `A` because it is not a valid `typeof` return value
if (a1 instanceof A) {}       // (2) Cannot reference type `A` [1] from a value position
(a1: A)                       // (3) Cannot cast `a1` to `A` because string [1] is incompatible with `A` [2]

if (a1.a) {                   // (4) Works, but inconclusive?
  a1.a = 2                    // (5) Cannot assign `2` to `a1.a` because number [1] is incompatible with string [2]
  a1.a = '2'                  // (6) Cannot assign `'2'` to `a1.a` because string [1] is incompatible with number [2]
}
user182917
  • 1,688
  • 2
  • 14
  • 17
  • 1
    (4) doesn't work, both `0` and `""` are falsy, and both `1` and `"x"` are truthy. – T.J. Crowder Dec 15 '20 at 09:32
  • 1
    I would have expected `typeof a1.a === "number"` to work (true = `A`, false = `B`), and it does in TypeScript, but Flow [doesn't seem to like it](https://flow.org/try/#0C4TwDgpgBAglC8UDeAoKUCGAuKA7ArgLYBGEATigL4oqiRQBCCyamOAzsGQJa4DmVGgGMA9rk6YAjDjgAfRswCyGYAAsAdGQy4AJiMIAKAJRQAPFAAM6gKxQA-MmxRJlKDiROARJM+UA3DTcAGZQBnQQIiEYkuoYCPCIngQk5J4mqOjRscwATAGuEAA27NAZUtmIAOQ5lfkoQA)... :-| – T.J. Crowder Dec 15 '20 at 09:38
  • @T.J.Crowder I'm sorry I didn't get about `0` and `""` being falsy and `1` and `"x"`, truthy. Could you write the statements which are falsy and truthy? Thanks – user182917 Dec 15 '20 at 09:48
  • 1
    (4) is `if (a1.a)`, but that doesn't do anything to tell you whether `a1` is an `A` or a `B`, because it will be false for both `0` (a number) and `""` (a string), and it will be true for both `1` (a number) and `"1"` (a string). That is, both `0` and `""` are *falsy* values (values that coerce to `false` when used in conditions). The full set of falsy values is: `0`, `""`, `NaN`, `null`, `undefined`, and of course `false` (also `document.all` on browsers for...reasons). All other values are *truthy*. – T.J. Crowder Dec 15 '20 at 09:51
  • 2
    Maybe this post can help: https://stackoverflow.com/questions/51528780/typescript-check-typeof-against-custom-type – secan Dec 15 '20 at 10:04
  • @secan, About the runtime erase, I know, but was thinking about a design-time `typeof` behavior in Flowtype, of which there seems to be one, but about constructing new values instead of checking for types. About checking for known values of the type, that's not a solution because I'm looking for a design-time solution and that's runtime. About the type-guard solution, it uses a 'is' keyword exclusive to Typescript, so, not available in Flowtype. – user182917 Dec 15 '20 at 11:08
  • @T.J.Crowder Oh, I see. So that's the runtime behavior of JavaScript, in use? I was thinking that was treated by Flowtype at design-time doing some kind of typechecking inside of its type system. Really, looking at some random Flowtype/Typescript code now, it's not immediately clear what's treated by the compiler, and what's left for JavaScript. Thanks for the explanation. – user182917 Dec 15 '20 at 11:12

1 Answers1

1

First I'm gonna walk through this code just to clarify what's going on:

type A = {
  a: number
}

type B = {
  a: string
}

const a1: A | B = {a: 1}

The constant a1 has been declared to be of the type that is a union of two object types, A and B.

if (typeof(a1) === 'A') {}    // (1) Cannot compare the result of `typeof` to string literal `A` because it is not a valid `typeof` return value

The problem here is that we're confusing value-level with type-level. This is sort of confusing because flow shares its type-level typeof keyword with javascript's value-level typeof. These are two different things and don't interoperate. We can consider the type-level flow typeof syntax to be a compile-time operation and the value-level javascript typeof syntax to be a runtime operation. The important thing about this distinction is that while flow has some awareness of values at compile-time, javascript has no awareness of types at runtime.

So in this example we're getting a runtime value typeof(a1) and trying to figure out how to compare it with the compile-time type A, and use this comparison for type refinement. The problem with this is that type refinement must operate at both compile-time and runtime, but runtime will have no awareness of the type A. Here we're trying to figure out how to refer to A because flow does not give us a way to do this:

if (typeof(a1) === 'A') // How can we refer to `A` at runtime? We can't.

The other problem here is that typeof a1 is going to return 'object' in all cases because this is literally just the regular javascript runtime typeof keyword, and typeof returns 'object' for all object instances.

if (a1 instanceof A) {}       // (2) Cannot reference type `A` [1] from a value position

This is basically the same problem, but it's more clear what's going on in this example. Flow is telling us exactly what the problem is, we're trying to reference a type (A) from a value position, rather than a type position. instanceof is runtime check, so it can't be used to compare against types.

(a1: A)                       // (3) Cannot cast `a1` to `A` because string [1] is incompatible with `A` [2]

Here we're attempting a type cast / assertion, which doesn't work basically because a1 has not yet been refined to A, and it so as far as flow knows it could still be B, and it would be unsafe to not deal with that case.

if (a1.a) {                   // (4) Works, but inconclusive?
  a1.a = 2                    // (5) Cannot assign `2` to `a1.a` because number [1] is incompatible with string [2]
  a1.a = '2'                  // (6) Cannot assign `'2'` to `a1.a` because string [1] is incompatible with number [2]
}

This is actually a valid refinement operation, but it only refines the property a1.a as existing. For example we can do something like this:

type A = {
  member: string,
};

type B = {

};

declare var c: A | B;

if (c.member) {
  console.log(c.member); // c.member is of type `mixed`
}

(try)

Here we have refined c.member to existing, but we don't know anything else about it so its type is mixed. This means we can't do that much with it. The important thing here, is that the type of c is still A | B, refining its property did not refine the containing type at all.

Now let's get to the real question:

Which would be the correct way to refine to one of the types in a union, where the type is not a [union of] primitive [types] (i.e. is a [union of object types])?

If the goal is to actually refine at the top level which object type something is, there is currently only one way to do this in flow: Use a disjoint union. A disjoint union relies on a special flag member of object types that must be of a different literal type for each member of the union. This means that if you use something like string it won't be recognized as a disjoint union:

type A = {
  // We're calling this `type` but it could have any name as long
  // as it's the same among each member of the union.
  type: 'a', // This must be a literal type.
  aMember: string,
};

type B = {
  type: 'b', // This must be a different literal type.
  bMember: number,
};

declare var c: A | B;

if (c.type === 'a') {
  // Within this branch, type of `c` is `A`.
  (c.aMember: string);
  // $FlowFixMe
  (c.bMember: number);
} else {
  // Within this branch, type of `c` is `B`.
  (c.bMember: number);
  // $FlowFixMe
  (c.aMember: string);
}

(try)

Lyle Underwood
  • 1,304
  • 7
  • 12