Since every Bar is also a Foo, the union Foo | Bar is equivalent to just Foo, in terms of which values can be assigned to them. In some sense, Foo | Bar contains no more information than just Foo, much like string | "hello" contains the same information as just string.
When the compiler explicitly converts Foo | Bar to Foo, this is called "subtype reduction" (as implemented in microsoft/TypeScript#4537). This is sound but sometimes annoying because while technically the | Bar doesn't give you any more information about what types of values might be there, pragmatically speaking it gives you a hint that maybe the y property can be used to determine what it is.
The in operator is treated as a type guard (as implemented in microsoft/TypeScript#15256) where the presence of a property is used to narrow a union type. This is useful but unsound. Pragmatically speaking, you expect that if an {a: 0} | {b: 1} contains an a property then it's an {a: 0}, but technically speaking you can't assume that. After all, the value {a: "oops", b: 1} is a valid {a: 0} | {b: 1} but not a valid {a: 0}. The mere presence of an a property does not technically tell you enough to narrow the type, but pragmatically it does.
These two ways of treating properties and unions are at odds with each other, and in a case like yours they interact poorly.
First Foo | Bar gets reduced to Foo (technically correct), which is not known to have a y property. Then you use the in operator to check for a y property, which decides that it cannot be a Foo at all due to its lack of a known y property (technically incorrect).
There's an issue microsoft/TypeScript#37518 which seems to be exactly this but it's just marked as "needs investigation". I'm pretty sure that this is just a design limitation and not a bug, per se. Each feature is working as advertised.
So what can be done? There are a few approaches. One way is to change the element type of arr from Foo | Bar to either a disjoint union or a non-union type, like either of these:
type FooNotBar = { x: number, y?: never };
function q(arr: (FooNotBar | Bar)[]) {
const b = arr.length ? arr[0] : null;
if (b && typeof b.y !== "undefined") {
console.log(b.y.toUpperCase()); // okay
}
}
type FooOrBar = { x: number, y?: string };
function r(arr: (FooOrBar)[]) {
const b = arr.length ? arr[0] : null;
if (b && typeof b.y !== "undefined") {
console.log(b.y.toUpperCase()); // okay
}
}
Here, FooNotBar is a Foo which is not a Bar because it cannot have a defined y property. So FooNotBar | Bar can use the y property to determine which type it is. Equivalently, FooOrBar is a Foo which is either a Bar or a non-Bar because y will either be a string or it will not be defined. Again, you can check the y property to determine which. Note that neither of those work with the in type guard, though, since it no longer can use the mere presence of the y key to determine which type you have.
Another approach is to keep your Foo | Bar and let it be collapsed to Foo by the compiler, but to use a user-defined type guard function to give your own desired behavior instead of the default in type guard behavior:
const isBar = (x: Foo): x is Bar => 'y' in x;
function s(arr: (Foo | Bar)[]) {
const b = arr.length ? arr[0] : null;
if (b && isBar(b)) {
console.log(b.y.toUpperCase()); // okay
}
}
Here you are saying "if I call isBar(foo), a true result means that foo can be treated like a Bar". The implementation of isBar() just uses in, but the signature of isBar() communicates the intent.
Playground link to code