-
Notifications
You must be signed in to change notification settings - Fork 12.8k
Incorrect narrowing of Union type after discrimination when one type is assignable to the other (even when explicit type annotation is present) #56106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
This is expected behavior as you union (even explicit) will be widened to just You would need something like #12936 to prevent this behavior |
Hmm... Here's another example that might better illustrate the problem: this works: const fn1 = (obj: WithFlags | WithoutFlags) => {
if ('flags' in obj) {
// no error, because 'obj' is narrowed to WithFlags
obj.flags[0]
}
} this doesn't: const fn2 = (wrapperObj: ObjWithFlags | ObjWithoutFlags) => {
const obj = 'withFlags' in wrapperObj ? wrapperObj.withFlags : wrapperObj.withoutFlags
// ^^ WithoutFlags ^^ WithFlags ^^ WithoutFlags
// ^^ should be: WithFlags | WithoutFlags
if ('flags' in obj) {
// error:
obj.flags[0]
}
} The only difference is that in the 2nd case the type is wrapped. See Playground. If this were somehow expected behavior then it's extremely unintuitive and I'd argue it should be changed. Removing a property from one of the types, so that it accidentally becomes assignable to the other shouldn't change how the rest of the surrounding code works. That's some wild spooky behavior at a distance. 😄 Esp. in our case this is a type we cannot change, because it's generated directly from GraphQL types - I can't easily add a fake property to it. |
This is unfortunate but all of this builds on top of things that are pretty unchangeable in TypeScript today. The declared type on a variable isn't always used as the final initial~ type of that variable, see Ryan's comment here. The other bit is just a subtype reduction at play and it's something that you can't easily fight here. To work around this you can introduce a function to "hide" your conditional expression it it and annotate the return type of that function. Or you can... use the const fn2 = (wrapperObj: ObjWithFlags | ObjWithoutFlags) => {
const obj = ("withFlags" in wrapperObj
? wrapperObj.withFlags
: wrapperObj.withoutFlags) satisfies WithFlags | WithoutFlags as
| WithFlags
| WithoutFlags;
if ("flags" in obj) {
// works!
obj.flags[0];
}
}; |
If they're interfaces you should be able to use declaration merging. |
But in that case, why does TypeScript behave correctly when the same union type is used in the annotation for an argument of a function, but incorrectly when used as a type annotation for a variable? Why would these two be different? This seems internally inconsistent.
Unfortunately no, they're defined as types. Even if they were interfaces, they could be nested object unions, which means declaration merging wouldn't help. I don't think we should assume the user has full control over the types in any situation. |
That annotation is the only information about the parameter's type. The parameter isn't subject to the control flow analysis. |
@RyanCavanaugh Out of curiosity, why is this considered a bug? AFAICT this comes down to just another inconvenient subtype reduction which historically has not been considered to be a defect. |
Automatic narrowing happens when defining an array with types of various interfaces (but not classes): // automatic narrowing to WithoutFlags:
const array1 = [withFlags, withoutFlags]
// ^? WithoutFlags[]
// but I can provide the type manually:
const array2: (WithFlags | WithoutFlags)[] = [withFlags, withoutFlags]
// ^? (WithFlags | WithoutFlags)[] and @IllusionMH mentions the handbook, but the example it gives behaves exactly as I would expect. By default it keeps the union intact, but you can provide an annotation to widen the type to use the common parent class ( It seems the reason it works that was is that the example in the handbook uses class Animal {}
class Rhino extends Animal {}
class Elephant extends Animal {trunk = true}
let zoo = [new Rhino(), new Elephant()];
// ^? (Rhino | Elephant)[]
interface IAnimal {}
interface IRhino extends IAnimal {}
interface IElephant extends IAnimal {trunk: true}
declare const rhino: IRhino
declare const elephant: IElephant
let izoo = [rhino, elephant]
// ^? IRhino[] I understand that with structural typing And given that classes are also plain objects that are compared structurally, why would a class instance be treated differently from an object interface in this case? My main question is: Why in such a case is assignability checked only one way, but not the other? (i.e. is This is what causes the spooky behavior at a distance, in that adding a property to the other interface, suddenly causes the type to flip to a union. Note that with these examples you at the very least have a workaround whereby you can specify the explicit type manually. |
Subtype reduction is indeed lossy, but the information you're losing is only that which leads to unsoundness anyway:
To clarify that remark, in the context of the original example: export interface WithWeirdFlags {
id: string
flags: bigint
}
let weird: WithWeirdFlags = { id: "fooey", flags: 777n };
let noFlags: WithoutFlags = weird; // valid subtype, so no error
let wut = noFlags as WithFlags | WithoutFlags; // cast to avoid CF narrowing
// later...
if ('flags' in wut) {
wut.flags // string[], oh no!
} In other words, because object types are not sealed (see #12936), the reduced type is arguably more correct and definitely more typesafe. |
@fatcerberus I don't buy it. You introduce unsoundness on this line: let wut = noFlags as WithFlags | WithoutFlags; // cast to avoid CF narrowing By casting the type, you manually tell the compiler that it could be either this or that, even though the union you cast it to is incorrect, because it omits the But this is a step you're taking manually. Here, we're talking about what the decision the compiler makes, and also the fact that you cannot even override these decisions by explicitly stating the type annotation. |
@niieani The cast isn't unsound. export interface WithWeirdFlags {
id: string
flags: bigint
}
let weird: WithWeirdFlags = { id: "fooey", flags: 777n };
wut(weird); // valid subtype of WithoutFlags, so no error
function wut(value: WithFlags | WithoutFlags) {
if ('flags' in value) {
value.flags // string[], oh no!
}
} The point is: |
To put it more simply: If you have an |
I see. The example with the function makes more sense, thank you. Even though the argument is expected to be a union of My proposed improvement to the compiler would be to make that call illegal, unless cast to the subtype first. |
You're ultimately looking for #12936 then. It's by design that objects can have extra properties not mentioned in their type. There is an excess property check for directly-provided object literals, by that's mainly just a lint check to detect typos and is not a real type rule. |
Not exactly. Exact types would help to solve this problem, yes, but I think a general improvement to current behavior as described above would help everyone, even those not using exact types today. My proposal is to:
Exact types have their use cases, but I think overall usability of TS could improve significantly if an algorithm like the above would be implemented to non-exact types. Of course, there's a matter of how much of a breaking change would this be. Would be interesting to test that for sure. |
This may be related to a bug I seem to have found related to using a discriminated type and guards.
|
This problem also appears while using nullish coalescing. interface A {
a: string;
}
interface B extends A {
b: number;
}
declare function f1(): A | undefined;
declare function f2(): B;
const x = f1() ?? f2();
// ^? A
if ('b' in x) {
x.b;
// ^? unknown
} It would be In any case, why would I want to lose the type information from |
🔎 Search Terms
"discriminating assignable union", "narrowing unions", "unions simplifying with type guards", "type narrowing with union types", "type discrimination with assignable types", "union type annotation issue", "type guard narrowing problem"
🕗 Version & Regression Information
⏯ Playground Link
💻 Code
🙁 Actual behavior
When accessing properties of a discriminated union type, where one resulting type is assignable to the other, TypeScript incorrectly narrows down the resulting type to the intersection of its types, rather than their union. Simply adding an additional property to the type that's assignable to the other makes TypeScript switch behavior, and use a union type instead.
This occurs even when an explicit type annotation is present on the variable. This behavior is observed not just with conditional ternary expressions but with any type guards.
This leads to an erroneous assumption, for instance, that the object type only contains the fields of the simpler type - in the example case, it is always of type
WithoutFlags
, even if the explicit annotation indicates a union ofWithFlags | WithoutFlags
.🙂 Expected behavior
TypeScript should always create unions of possible types, unless both types are mutually assignable. Currently it's enough if one of the types is assignable to the other.
When a variable is annotated with an explicit type, TypeScript should disregard any magic inference behavior that happens in its initializer
Additional information about the issue
We've stumbled upon this issue accidentally in the real world, because code that used to work, suddenly was erroring. A refactor of GraphQL Fragments that unified two distinct property types into one, suddenly made TypeScript behave as if only one of those distinct types was correct, and all the excess properties were missing, even after applying a type guard to test for which one of the types is being used.
The text was updated successfully, but these errors were encountered: