diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index d08254cc8ef88..3de5b661c1e51 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -707,7 +707,7 @@ func (c *Checker) narrowTypeByDiscriminantProperty(t *Type, access *ast.Node, op candidate := c.getConstituentTypeForKeyType(t, c.getTypeOfExpression(value)) if candidate != nil { if assumeTrue && operator == ast.KindEqualsEqualsEqualsToken || !assumeTrue && operator == ast.KindExclamationEqualsEqualsToken { - return candidate + return c.addNullableConstituents(candidate, t) } if propType := c.getTypeOfPropertyOfType(candidate, keyPropertyName); propType != nil && isUnitType(propType) { return c.removeType(t, candidate) @@ -722,6 +722,13 @@ func (c *Checker) narrowTypeByDiscriminantProperty(t *Type, access *ast.Node, op }) } +// Nullable constituents have no key property, so the general discriminant narrowing logic in +// narrowTypeByDiscriminant always retains them in branches where the discriminant can match. Add +// them back to a constituent selected through the key property map to produce the same result. +func (c *Checker) addNullableConstituents(candidate *Type, t *Type) *Type { + return c.getUnionType([]*Type{candidate, c.filterType(t, func(t *Type) bool { return t.flags&TypeFlagsNullable != 0 })}) +} + func (c *Checker) narrowTypeByDiscriminant(t *Type, access *ast.Node, narrowType func(t *Type) *Type) *Type { propName, ok := c.getAccessedPropertyName(access) if !ok { @@ -1241,7 +1248,7 @@ func (c *Checker) narrowTypeBySwitchOnDiscriminantProperty(t *Type, access *ast. return c.unknownType })) if candidate != c.unknownType { - return candidate + return c.addNullableConstituents(candidate, t) } } } diff --git a/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types b/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types index 0e44a18ee2844..3470ec943bbb3 100644 --- a/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types +++ b/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types @@ -83,9 +83,9 @@ if (working.thing!.name !== "Correct") { >log : (...data: any[]) => void >working.thing!.id : string >working.thing! : { name: 'Correct'; id: string; } ->working.thing : { name: 'Correct'; id: string; } +>working.thing : { name: 'Correct'; id: string; } | undefined >working : WorkingType ->thing : { name: 'Correct'; id: string; } +>thing : { name: 'Correct'; id: string; } | undefined >id : string } diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts new file mode 100644 index 0000000000000..3483b55208732 --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -0,0 +1,117 @@ +// @strict: true +// @noEmit: true +// @noTypesAndSymbols: true + +// A non-null assertion narrows only the asserted expression itself; it does not establish a +// persistent non-null flow fact for the underlying reference. This must hold identically for +// unions small enough to use the fallback discriminant narrowing path and unions large enough +// to use the optimized key-property path, so that a `!` assertion behaves consistently +// regardless of union size (#62511). Optional chaining remains the idiomatic way to narrow the +// reference itself and continues to do so unchanged. + +type Small = + | { type: "1" } + | { type: "2" } + | undefined; + +type Large = + | { type: "1" } + | { type: "2" } + | { type: "3" } + | { type: "4" } + | { type: "5" } + | { type: "6" } + | { type: "7" } + | { type: "8" } + | { type: "9" } + | { type: "10" } + | undefined; + +type LargeWithNull = Exclude | null; + +// Strict equality, both branches. + +declare let smallEqual: Small; +if (smallEqual!.type === "1") { + // @ts-expect-error + smallEqual.type; +} else { + // @ts-expect-error + smallEqual.type; +} + +declare let largeEqual: Large; +if (largeEqual!.type === "1") { + // @ts-expect-error + largeEqual.type; +} else { + // @ts-expect-error + largeEqual.type; +} + +// Strict inequality. + +declare let smallNotEqual: Small; +if (smallNotEqual!.type !== "1") { + // @ts-expect-error + smallNotEqual.type; +} + +declare let largeNotEqual: Large; +if (largeNotEqual!.type !== "1") { + // @ts-expect-error + largeNotEqual.type; +} + +// Switch. + +declare let smallSwitch: Small; +switch (smallSwitch!.type) { + case "1": + // @ts-expect-error + smallSwitch.type; + break; +} + +declare let largeSwitch: Large; +switch (largeSwitch!.type) { + case "1": + // @ts-expect-error + largeSwitch.type; + break; +} + +// `null` constituents are preserved just like `undefined`. + +declare let largeNullEqual: LargeWithNull; +if (largeNullEqual!.type === "1") { + // @ts-expect-error + largeNullEqual.type; +} + +declare let largeNullSwitch: LargeWithNull; +switch (largeNullSwitch!.type) { + case "1": + // @ts-expect-error + largeNullSwitch.type; + break; +} + +// Control: optional chaining is unaffected and remains the idiomatic way to narrow the +// reference itself, for both union sizes. + +declare let smallOptional: Small; +if (smallOptional?.type === "1") { + smallOptional.type; +} else { + // @ts-expect-error + smallOptional.type; +} + +declare let largeOptional: Large; +if (largeOptional?.type === "1") { + largeOptional.type; +} else { + // @ts-expect-error + largeOptional.type; +}