From bc24da7306f49bebc1dd1a902ea83198ce262a35 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 10:49:11 +0900 Subject: [PATCH 01/13] fix: narrow discriminant access after non-null assertion --- tsc/internal/checker/flow.go | 3 + .../compiler/narrowingUnionWithBang.types | 12 ++-- ...iscriminatedUnionNonNullAccessNarrowing.ts | 70 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index d08254cc8ef88..feff1616523f0 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -700,6 +700,9 @@ func (c *Checker) narrowTypeByTypeFacts(t *Type, impliedType *Type, facts TypeFa } func (c *Checker) narrowTypeByDiscriminantProperty(t *Type, access *ast.Node, operator ast.Kind, value *ast.Node, assumeTrue bool) *Type { + if c.strictNullChecks && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) + } if (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && t.flags&TypeFlagsUnion != 0 { keyPropertyName := c.getKeyPropertyName(t) if keyPropertyName != "" { diff --git a/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types b/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types index 0e44a18ee2844..7f7b2d66e4010 100644 --- a/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types +++ b/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types @@ -70,9 +70,9 @@ if (working.thing!.name !== "Correct") { >log : (...data: any[]) => void >working.thing!.message : string >working.thing! : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } ->working.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } | undefined +>working.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } >working : WorkingType ->thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } | undefined +>thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } >message : string } else { @@ -154,9 +154,9 @@ if (borked.thing!.name !== "Correct") { >log : (...data: any[]) => void >borked.thing!.message : string >borked.thing! : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } ->borked.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | undefined +>borked.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } >borked : BorkedType ->thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | undefined +>thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } >message : string } else { @@ -167,9 +167,9 @@ if (borked.thing!.name !== "Correct") { >log : (...data: any[]) => void >borked.thing!.id : string >borked.thing! : { name: 'Correct'; id: string; } ->borked.thing : { name: 'Correct'; id: string; } | undefined +>borked.thing : { name: 'Correct'; id: string; } >borked : BorkedType ->thing : { name: 'Correct'; id: string; } | undefined +>thing : { name: 'Correct'; id: string; } >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..e4e281d77c0ac --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -0,0 +1,70 @@ +// @strict: true +// @noEmit: true +// @noTypesAndSymbols: true + +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; + +// Small union: fallback discriminant narrowing path. + +declare let smallEqual: Small; +if (smallEqual!.type === "1") { + smallEqual.type; +} + +declare let smallElse: Small; +if (smallElse!.type === "1") { +} else { + smallElse.type; +} + +declare let smallNotEqual: Small; +if (smallNotEqual!.type !== "1") { + smallNotEqual.type; +} + +// Large union: optimized discriminant narrowing path. + +declare let largeEqual: Large; +if (largeEqual!.type === "1") { + largeEqual.type; +} + +declare let largeElse: Large; +if (largeElse!.type === "1") { +} else { + largeElse.type; +} + +declare let largeNotEqual: Large; +if (largeNotEqual!.type !== "1") { + largeNotEqual.type; +} + +// Control cases: optional chaining already propagates non-nullability +// into the matching branch. + +declare let smallOptional: Small; +if (smallOptional?.type === "1") { + smallOptional.type; +} + +declare let largeOptional: Large; +if (largeOptional?.type === "1") { + largeOptional.type; +} From 857b696f63ebdc6e137050e8e0ce599e5834dab0 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 12:32:54 +0900 Subject: [PATCH 02/13] fix: preserve nullability for aliased discriminants --- tsc/internal/checker/flow.go | 16 +++++--- ...iscriminatedUnionNonNullAccessNarrowing.ts | 38 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index feff1616523f0..dc639a29c05e9 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -495,10 +495,16 @@ func (c *Checker) narrowTypeByBinaryExpression(f *FlowState, t *Type, expr *ast. } leftAccess := c.getDiscriminantPropertyAccess(f, left, t) if leftAccess != nil { + if leftAccess == left && c.strictNullChecks && isNonNullAccess(leftAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) + } return c.narrowTypeByDiscriminantProperty(t, leftAccess, operator, right, assumeTrue) } rightAccess := c.getDiscriminantPropertyAccess(f, right, t) if rightAccess != nil { + if rightAccess == right && c.strictNullChecks && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) + } return c.narrowTypeByDiscriminantProperty(t, rightAccess, operator, left, assumeTrue) } if c.isMatchingConstructorReference(f, left) { @@ -700,10 +706,7 @@ func (c *Checker) narrowTypeByTypeFacts(t *Type, impliedType *Type, facts TypeFa } func (c *Checker) narrowTypeByDiscriminantProperty(t *Type, access *ast.Node, operator ast.Kind, value *ast.Node, assumeTrue bool) *Type { - if c.strictNullChecks && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) { - t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) - } - if (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && t.flags&TypeFlagsUnion != 0 { + if (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && t.flags&TypeFlagsUnion != 0 && (!c.strictNullChecks || !c.maybeTypeOfKind(t, TypeFlagsNullable)) { keyPropertyName := c.getKeyPropertyName(t) if keyPropertyName != "" { if accessedName, ok := c.getAccessedPropertyName(access); ok && keyPropertyName == accessedName { @@ -1085,6 +1088,9 @@ func (c *Checker) getTypeAtSwitchClause(f *FlowState, flow *ast.FlowNode) FlowTy } access := c.getDiscriminantPropertyAccess(f, expr, t) if access != nil { + if access == expr && c.strictNullChecks && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) + } t = c.narrowTypeBySwitchOnDiscriminantProperty(t, access, data) } } @@ -1232,7 +1238,7 @@ func (c *Checker) narrowTypeBySwitchOptionalChainContainment(t *Type, data *ast. } func (c *Checker) narrowTypeBySwitchOnDiscriminantProperty(t *Type, access *ast.Node, data *ast.FlowSwitchClauseData) *Type { - if data.ClauseStart < data.ClauseEnd && t.flags&TypeFlagsUnion != 0 { + if data.ClauseStart < data.ClauseEnd && t.flags&TypeFlagsUnion != 0 && (!c.strictNullChecks || !c.maybeTypeOfKind(t, TypeFlagsNullable)) { accessedName, _ := c.getAccessedPropertyName(access) if accessedName != "" && c.getKeyPropertyName(t) == accessedName { clauseTypes := c.getSwitchClauseTypes(data.SwitchStatement)[data.ClauseStart:data.ClauseEnd] diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index e4e281d77c0ac..68c54a7ece1cc 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -38,6 +38,13 @@ if (smallNotEqual!.type !== "1") { smallNotEqual.type; } +declare let smallSwitch: Small; +switch (smallSwitch!.type) { + case "1": + smallSwitch.type; + break; +} + // Large union: optimized discriminant narrowing path. declare let largeEqual: Large; @@ -56,6 +63,13 @@ if (largeNotEqual!.type !== "1") { largeNotEqual.type; } +declare let largeSwitch: Large; +switch (largeSwitch!.type) { + case "1": + largeSwitch.type; + break; +} + // Control cases: optional chaining already propagates non-nullability // into the matching branch. @@ -68,3 +82,27 @@ declare let largeOptional: Large; if (largeOptional?.type === "1") { largeOptional.type; } + +// A non-null assertion captured by an alias does not apply after the source is reassigned. + +declare let aliasedValue: Small; +declare let maybeUndefined: Small; +const aliasedTag = aliasedValue!.type; + +aliasedValue = maybeUndefined; + +if (aliasedTag === "1") { + // @ts-expect-error + aliasedValue.type; +} + +declare let largeAliasedValue: Large; +declare let maybeLargeUndefined: Large; +const largeAliasedTag = largeAliasedValue!.type; + +largeAliasedValue = maybeLargeUndefined; + +if (largeAliasedTag === "1") { + // @ts-expect-error + largeAliasedValue.type; +} From b57d7fd96abef07092ea9fb249fe33983d7201f8 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 14:08:22 +0900 Subject: [PATCH 03/13] fix: preserve optional-chain nullability --- tsc/internal/checker/flow.go | 6 ++--- ...iscriminatedUnionNonNullAccessNarrowing.ts | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index dc639a29c05e9..8fe31b797469e 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -495,14 +495,14 @@ func (c *Checker) narrowTypeByBinaryExpression(f *FlowState, t *Type, expr *ast. } leftAccess := c.getDiscriminantPropertyAccess(f, left, t) if leftAccess != nil { - if leftAccess == left && c.strictNullChecks && isNonNullAccess(leftAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if leftAccess == left && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, leftAccess, operator, right, assumeTrue) } rightAccess := c.getDiscriminantPropertyAccess(f, right, t) if rightAccess != nil { - if rightAccess == right && c.strictNullChecks && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if rightAccess == right && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, rightAccess, operator, left, assumeTrue) @@ -1088,7 +1088,7 @@ func (c *Checker) getTypeAtSwitchClause(f *FlowState, flow *ast.FlowNode) FlowTy } access := c.getDiscriminantPropertyAccess(f, expr, t) if access != nil { - if access == expr && c.strictNullChecks && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } t = c.narrowTypeBySwitchOnDiscriminantProperty(t, access, data) diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 68c54a7ece1cc..72ebc7a44426c 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -83,6 +83,33 @@ if (largeOptional?.type === "1") { largeOptional.type; } +declare let optionalBangLeft: Small; +if (optionalBangLeft!?.type === "1") { + optionalBangLeft.type; +} else { + // @ts-expect-error + optionalBangLeft.type; +} + +declare let optionalBangRight: Small; +if ("1" === optionalBangRight!?.type) { + optionalBangRight.type; +} else { + // @ts-expect-error + optionalBangRight.type; +} + +declare let optionalBangSwitch: Small; +switch (optionalBangSwitch!?.type) { + case "1": + optionalBangSwitch.type; + break; + default: + // @ts-expect-error + optionalBangSwitch.type; + break; +} + // A non-null assertion captured by an alias does not apply after the source is reassigned. declare let aliasedValue: Small; From e4900c6fc95e936d29af5ff2b289391901b486ec Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 14:15:25 +0900 Subject: [PATCH 04/13] test: cover mirrored non-null discriminant access --- .../compiler/discriminatedUnionNonNullAccessNarrowing.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 72ebc7a44426c..544eff9fe2a8d 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -27,6 +27,11 @@ if (smallEqual!.type === "1") { smallEqual.type; } +declare let smallEqualRight: Small; +if ("1" === smallEqualRight!.type) { + smallEqualRight.type; +} + declare let smallElse: Small; if (smallElse!.type === "1") { } else { From 11434b39bea817a09a0b786730daf33b12be9f6b Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 20:29:35 +0900 Subject: [PATCH 05/13] fix: limit eager narrowing to side-effect-free expressions --- tsc/internal/checker/flow.go | 23 ++++++++++++-- ...iscriminatedUnionNonNullAccessNarrowing.ts | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 8fe31b797469e..343c9d852814a 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -495,7 +495,9 @@ func (c *Checker) narrowTypeByBinaryExpression(f *FlowState, t *Type, expr *ast. } leftAccess := c.getDiscriminantPropertyAccess(f, left, t) if leftAccess != nil { - if leftAccess == left && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + // The right operand is evaluated after a left discriminant access, so only carry the + // non-null fact through expressions that cannot have observable side effects. + if leftAccess == left && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && isSideEffectFreeNonNullFactExpression(expr.Right) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, leftAccess, operator, right, assumeTrue) @@ -1088,7 +1090,7 @@ func (c *Checker) getTypeAtSwitchClause(f *FlowState, flow *ast.FlowNode) FlowTy } access := c.getDiscriminantPropertyAccess(f, expr, t) if access != nil { - if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && switchClauseExpressionsAreSideEffectFree(data) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } t = c.narrowTypeBySwitchOnDiscriminantProperty(t, access, data) @@ -1857,6 +1859,23 @@ func (c *Checker) containsMatchingReference(source *ast.Node, target *ast.Node) return false } +func isSideEffectFreeNonNullFactExpression(node *ast.Node) bool { + node = ast.SkipOuterExpressions(node, ast.OEKAll) + return ast.IsStringLiteralLike(node) || ast.IsNumericLiteral(node) || ast.IsBigIntLiteral(node) || ast.IsBooleanLiteral(node) || + node.Kind == ast.KindNullKeyword || ast.IsIdentifier(node) +} + +func switchClauseExpressionsAreSideEffectFree(data *ast.FlowSwitchClauseData) bool { + clauses := data.SwitchStatement.AsSwitchStatement().CaseBlock.AsCaseBlock().Clauses.Nodes + clauseEnd := int(data.ClauseEnd) + if data.IsEmpty() || core.Some(clauses[data.ClauseStart:data.ClauseEnd], func(clause *ast.Node) bool { return clause.Kind == ast.KindDefaultClause }) { + clauseEnd = len(clauses) + } + return core.Every(clauses[:clauseEnd], func(clause *ast.Node) bool { + return clause.Kind == ast.KindDefaultClause || isSideEffectFreeNonNullFactExpression(clause.Expression()) + }) +} + func (c *Checker) optionalChainContainsReference(source *ast.Node, target *ast.Node) bool { for ast.IsOptionalChain(source) { source = source.Expression() diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 544eff9fe2a8d..3940e43a268fc 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -32,6 +32,12 @@ if ("1" === smallEqualRight!.type) { smallEqualRight.type; } +declare const one: "1"; +declare let smallEqualIdentifier: Small; +if (smallEqualIdentifier!.type === one) { + smallEqualIdentifier.type; +} + declare let smallElse: Small; if (smallElse!.type === "1") { } else { @@ -138,3 +144,28 @@ if (largeAliasedTag === "1") { // @ts-expect-error largeAliasedValue.type; } + +// Later-evaluated expressions with possible side effects do not carry the non-null fact. + +declare let assignmentInRight: Small; +if (assignmentInRight!.type === (assignmentInRight = maybeUndefined, "1")) { + // @ts-expect-error + assignmentInRight.type; +} + +declare let invocationInRight: Small; +if (invocationInRight!.type === (() => { + invocationInRight = maybeUndefined; + return "1" as const; +})()) { + // @ts-expect-error + invocationInRight.type; +} + +declare let assignmentInCase: Small; +switch (assignmentInCase!.type) { + case (assignmentInCase = maybeUndefined, "1"): + // @ts-expect-error + assignmentInCase.type; + break; +} From f6291ea3e7059db32700037d811e7e11f9e990e3 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 20:42:14 +0900 Subject: [PATCH 06/13] fix: restrict eager narrowing to direct strict comparisons --- tsc/internal/checker/flow.go | 4 ++-- ...iscriminatedUnionNonNullAccessNarrowing.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 343c9d852814a..5d59ba3a58354 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -497,14 +497,14 @@ func (c *Checker) narrowTypeByBinaryExpression(f *FlowState, t *Type, expr *ast. if leftAccess != nil { // The right operand is evaluated after a left discriminant access, so only carry the // non-null fact through expressions that cannot have observable side effects. - if leftAccess == left && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && isSideEffectFreeNonNullFactExpression(expr.Right) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if leftAccess == left && ast.SkipParentheses(expr.Left) == leftAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && isSideEffectFreeNonNullFactExpression(expr.Right) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, leftAccess, operator, right, assumeTrue) } rightAccess := c.getDiscriminantPropertyAccess(f, right, t) if rightAccess != nil { - if rightAccess == right && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if rightAccess == right && ast.SkipParentheses(expr.Right) == rightAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, rightAccess, operator, left, assumeTrue) diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 3940e43a268fc..1c874ea819bb0 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -153,6 +153,30 @@ if (assignmentInRight!.type === (assignmentInRight = maybeUndefined, "1")) { assignmentInRight.type; } +declare let assignmentWrappedAccessLeft: Small; +if ((assignmentWrappedAccessLeft!.type = (assignmentWrappedAccessLeft = maybeUndefined, "1")) === "1") { + // @ts-expect-error + assignmentWrappedAccessLeft.type; +} + +declare let assignmentWrappedAccessRight: Small; +if ("1" === (assignmentWrappedAccessRight!.type = (assignmentWrappedAccessRight = maybeUndefined, "1"))) { + // @ts-expect-error + assignmentWrappedAccessRight.type; +} + +declare let looseEquality: Small; +const coercibleValue: any = { + valueOf() { + looseEquality = maybeUndefined; + return "1"; + }, +}; +if (looseEquality!.type == coercibleValue) { + // @ts-expect-error + looseEquality.type; +} + declare let invocationInRight: Small; if (invocationInRight!.type === (() => { invocationInRight = maybeUndefined; From 0b51865bdc1fd88973f313a97a6e256c9780c28c Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 20:58:42 +0900 Subject: [PATCH 07/13] fix: exclude computed keys from eager narrowing --- tsc/internal/checker/flow.go | 10 +++++++--- .../discriminatedUnionNonNullAccessNarrowing.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 5d59ba3a58354..18e7c8a75b00a 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -497,14 +497,14 @@ func (c *Checker) narrowTypeByBinaryExpression(f *FlowState, t *Type, expr *ast. if leftAccess != nil { // The right operand is evaluated after a left discriminant access, so only carry the // non-null fact through expressions that cannot have observable side effects. - if leftAccess == left && ast.SkipParentheses(expr.Left) == leftAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && isSideEffectFreeNonNullFactExpression(expr.Right) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if leftAccess == left && ast.SkipParentheses(expr.Left) == leftAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && isSideEffectFreeNonNullFactAccess(leftAccess) && isSideEffectFreeNonNullFactExpression(expr.Right) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, leftAccess, operator, right, assumeTrue) } rightAccess := c.getDiscriminantPropertyAccess(f, right, t) if rightAccess != nil { - if rightAccess == right && ast.SkipParentheses(expr.Right) == rightAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if rightAccess == right && ast.SkipParentheses(expr.Right) == rightAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && isSideEffectFreeNonNullFactAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } return c.narrowTypeByDiscriminantProperty(t, rightAccess, operator, left, assumeTrue) @@ -1090,7 +1090,7 @@ func (c *Checker) getTypeAtSwitchClause(f *FlowState, flow *ast.FlowNode) FlowTy } access := c.getDiscriminantPropertyAccess(f, expr, t) if access != nil { - if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && switchClauseExpressionsAreSideEffectFree(data) && c.maybeTypeOfKind(t, TypeFlagsNullable) { + if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && isSideEffectFreeNonNullFactAccess(access) && switchClauseExpressionsAreSideEffectFree(data) && c.maybeTypeOfKind(t, TypeFlagsNullable) { t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) } t = c.narrowTypeBySwitchOnDiscriminantProperty(t, access, data) @@ -1865,6 +1865,10 @@ func isSideEffectFreeNonNullFactExpression(node *ast.Node) bool { node.Kind == ast.KindNullKeyword || ast.IsIdentifier(node) } +func isSideEffectFreeNonNullFactAccess(node *ast.Node) bool { + return ast.IsPropertyAccessExpression(node) || ast.IsLiteralLikeElementAccess(node) +} + func switchClauseExpressionsAreSideEffectFree(data *ast.FlowSwitchClauseData) bool { clauses := data.SwitchStatement.AsSwitchStatement().CaseBlock.AsCaseBlock().Clauses.Nodes clauseEnd := int(data.ClauseEnd) diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 1c874ea819bb0..e2c03845a6d5f 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -177,6 +177,21 @@ if (looseEquality!.type == coercibleValue) { looseEquality.type; } +declare let computedKeyLeft: Small; +enum ComputedKeyLeft { + type = "type", +} +Object.defineProperty(ComputedKeyLeft, "type", { + get() { + computedKeyLeft = maybeUndefined; + return "type" as const; + }, +}); +if (computedKeyLeft![ComputedKeyLeft.type] === "1") { + // @ts-expect-error + computedKeyLeft.type; +} + declare let invocationInRight: Small; if (invocationInRight!.type === (() => { invocationInRight = maybeUndefined; From deb68a299e7f7ee928487bd9fa50811893028416 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 21:06:50 +0900 Subject: [PATCH 08/13] test: cover nullable optimized narrowing guards --- .../compiler/discriminatedUnionNonNullAccessNarrowing.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index e2c03845a6d5f..b935868f4d992 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -147,8 +147,8 @@ if (largeAliasedTag === "1") { // Later-evaluated expressions with possible side effects do not carry the non-null fact. -declare let assignmentInRight: Small; -if (assignmentInRight!.type === (assignmentInRight = maybeUndefined, "1")) { +declare let assignmentInRight: Large; +if (assignmentInRight!.type === (assignmentInRight = maybeLargeUndefined, "1")) { // @ts-expect-error assignmentInRight.type; } @@ -201,9 +201,9 @@ if (invocationInRight!.type === (() => { invocationInRight.type; } -declare let assignmentInCase: Small; +declare let assignmentInCase: Large; switch (assignmentInCase!.type) { - case (assignmentInCase = maybeUndefined, "1"): + case (assignmentInCase = maybeLargeUndefined, "1"): // @ts-expect-error assignmentInCase.type; break; From 8812a74c33ff029b92a42f8dde309d7ffbc41345 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Mon, 14 Sep 2026 21:19:35 +0900 Subject: [PATCH 09/13] fix: recognize signed primitive discriminants --- tsc/internal/checker/flow.go | 3 +-- .../compiler/discriminatedUnionNonNullAccessNarrowing.ts | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 18e7c8a75b00a..808ecb76efab6 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -1861,8 +1861,7 @@ func (c *Checker) containsMatchingReference(source *ast.Node, target *ast.Node) func isSideEffectFreeNonNullFactExpression(node *ast.Node) bool { node = ast.SkipOuterExpressions(node, ast.OEKAll) - return ast.IsStringLiteralLike(node) || ast.IsNumericLiteral(node) || ast.IsBigIntLiteral(node) || ast.IsBooleanLiteral(node) || - node.Kind == ast.KindNullKeyword || ast.IsIdentifier(node) + return ast.IsPrimitiveLiteralValue(node, true) || node.Kind == ast.KindNullKeyword || ast.IsIdentifier(node) } func isSideEffectFreeNonNullFactAccess(node *ast.Node) bool { diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index b935868f4d992..36afd3a4790df 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -38,6 +38,11 @@ if (smallEqualIdentifier!.type === one) { smallEqualIdentifier.type; } +declare let signedEqual: { type: -1 } | { type: 1 } | undefined; +if (signedEqual!.type === -1) { + signedEqual.type; +} + declare let smallElse: Small; if (smallElse!.type === "1") { } else { From d2b05ca84a2e2e294502e7410219b6843b28ec10 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Tue, 15 Sep 2026 08:56:21 +0900 Subject: [PATCH 10/13] Simplify non-null discriminant narrowing tests --- ...iscriminatedUnionNonNullAccessNarrowing.ts | 43 +++---------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 36afd3a4790df..f806124697868 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -25,6 +25,8 @@ type Large = declare let smallEqual: Small; if (smallEqual!.type === "1") { smallEqual.type; +} else { + smallEqual.type; } declare let smallEqualRight: Small; @@ -43,12 +45,6 @@ if (signedEqual!.type === -1) { signedEqual.type; } -declare let smallElse: Small; -if (smallElse!.type === "1") { -} else { - smallElse.type; -} - declare let smallNotEqual: Small; if (smallNotEqual!.type !== "1") { smallNotEqual.type; @@ -66,12 +62,8 @@ switch (smallSwitch!.type) { declare let largeEqual: Large; if (largeEqual!.type === "1") { largeEqual.type; -} - -declare let largeElse: Large; -if (largeElse!.type === "1") { } else { - largeElse.type; + largeEqual.type; } declare let largeNotEqual: Large; @@ -86,19 +78,15 @@ switch (largeSwitch!.type) { break; } -// Control cases: optional chaining already propagates non-nullability -// into the matching branch. - -declare let smallOptional: Small; -if (smallOptional?.type === "1") { - smallOptional.type; -} +// Ordinary optional chaining still narrows a nullable large union through the fallback path. declare let largeOptional: Large; if (largeOptional?.type === "1") { largeOptional.type; } +// Optional chaining through a non-null assertion preserves nullable non-matching branches. + declare let optionalBangLeft: Small; if (optionalBangLeft!?.type === "1") { optionalBangLeft.type; @@ -128,17 +116,7 @@ switch (optionalBangSwitch!?.type) { // A non-null assertion captured by an alias does not apply after the source is reassigned. -declare let aliasedValue: Small; declare let maybeUndefined: Small; -const aliasedTag = aliasedValue!.type; - -aliasedValue = maybeUndefined; - -if (aliasedTag === "1") { - // @ts-expect-error - aliasedValue.type; -} - declare let largeAliasedValue: Large; declare let maybeLargeUndefined: Large; const largeAliasedTag = largeAliasedValue!.type; @@ -197,15 +175,6 @@ if (computedKeyLeft![ComputedKeyLeft.type] === "1") { computedKeyLeft.type; } -declare let invocationInRight: Small; -if (invocationInRight!.type === (() => { - invocationInRight = maybeUndefined; - return "1" as const; -})()) { - // @ts-expect-error - invocationInRight.type; -} - declare let assignmentInCase: Large; switch (assignmentInCase!.type) { case (assignmentInCase = maybeLargeUndefined, "1"): From 592ccc7515b1df55d27cd14cd93812167c354923 Mon Sep 17 00:00:00 2001 From: z0rimo Date: Thu, 17 Sep 2026 10:54:26 +0900 Subject: [PATCH 11/13] fix: align nullable discriminant narrowing paths --- tsc/internal/checker/flow.go | 31 ---- .../compiler/narrowingUnionWithBang.types | 16 +- ...iscriminatedUnionNonNullAccessNarrowing.ts | 163 ++++-------------- 3 files changed, 44 insertions(+), 166 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 808ecb76efab6..63341a0cf0e70 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -495,18 +495,10 @@ func (c *Checker) narrowTypeByBinaryExpression(f *FlowState, t *Type, expr *ast. } leftAccess := c.getDiscriminantPropertyAccess(f, left, t) if leftAccess != nil { - // The right operand is evaluated after a left discriminant access, so only carry the - // non-null fact through expressions that cannot have observable side effects. - if leftAccess == left && ast.SkipParentheses(expr.Left) == leftAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(leftAccess) && isNonNullAccess(leftAccess) && isSideEffectFreeNonNullFactAccess(leftAccess) && isSideEffectFreeNonNullFactExpression(expr.Right) && c.maybeTypeOfKind(t, TypeFlagsNullable) { - t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) - } return c.narrowTypeByDiscriminantProperty(t, leftAccess, operator, right, assumeTrue) } rightAccess := c.getDiscriminantPropertyAccess(f, right, t) if rightAccess != nil { - if rightAccess == right && ast.SkipParentheses(expr.Right) == rightAccess && (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && isSideEffectFreeNonNullFactAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) { - t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) - } return c.narrowTypeByDiscriminantProperty(t, rightAccess, operator, left, assumeTrue) } if c.isMatchingConstructorReference(f, left) { @@ -1090,9 +1082,6 @@ func (c *Checker) getTypeAtSwitchClause(f *FlowState, flow *ast.FlowNode) FlowTy } access := c.getDiscriminantPropertyAccess(f, expr, t) if access != nil { - if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && isSideEffectFreeNonNullFactAccess(access) && switchClauseExpressionsAreSideEffectFree(data) && c.maybeTypeOfKind(t, TypeFlagsNullable) { - t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull) - } t = c.narrowTypeBySwitchOnDiscriminantProperty(t, access, data) } } @@ -1859,26 +1848,6 @@ func (c *Checker) containsMatchingReference(source *ast.Node, target *ast.Node) return false } -func isSideEffectFreeNonNullFactExpression(node *ast.Node) bool { - node = ast.SkipOuterExpressions(node, ast.OEKAll) - return ast.IsPrimitiveLiteralValue(node, true) || node.Kind == ast.KindNullKeyword || ast.IsIdentifier(node) -} - -func isSideEffectFreeNonNullFactAccess(node *ast.Node) bool { - return ast.IsPropertyAccessExpression(node) || ast.IsLiteralLikeElementAccess(node) -} - -func switchClauseExpressionsAreSideEffectFree(data *ast.FlowSwitchClauseData) bool { - clauses := data.SwitchStatement.AsSwitchStatement().CaseBlock.AsCaseBlock().Clauses.Nodes - clauseEnd := int(data.ClauseEnd) - if data.IsEmpty() || core.Some(clauses[data.ClauseStart:data.ClauseEnd], func(clause *ast.Node) bool { return clause.Kind == ast.KindDefaultClause }) { - clauseEnd = len(clauses) - } - return core.Every(clauses[:clauseEnd], func(clause *ast.Node) bool { - return clause.Kind == ast.KindDefaultClause || isSideEffectFreeNonNullFactExpression(clause.Expression()) - }) -} - func (c *Checker) optionalChainContainsReference(source *ast.Node, target *ast.Node) bool { for ast.IsOptionalChain(source) { source = source.Expression() diff --git a/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types b/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types index 7f7b2d66e4010..3470ec943bbb3 100644 --- a/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types +++ b/tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types @@ -70,9 +70,9 @@ if (working.thing!.name !== "Correct") { >log : (...data: any[]) => void >working.thing!.message : string >working.thing! : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } ->working.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } +>working.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } | undefined >working : WorkingType ->thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } +>thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | { name: 'Error9'; message: string; } | undefined >message : string } else { @@ -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 } @@ -154,9 +154,9 @@ if (borked.thing!.name !== "Correct") { >log : (...data: any[]) => void >borked.thing!.message : string >borked.thing! : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } ->borked.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } +>borked.thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | undefined >borked : BorkedType ->thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } +>thing : { name: 'Error1'; message: string; } | { name: 'Error2'; message: string; } | { name: 'Error3'; message: string; } | { name: 'Error4'; message: string; } | { name: 'Error5'; message: string; } | { name: 'Error6'; message: string; } | { name: 'Error7'; message: string; } | { name: 'Error8'; message: string; } | undefined >message : string } else { @@ -167,9 +167,9 @@ if (borked.thing!.name !== "Correct") { >log : (...data: any[]) => void >borked.thing!.id : string >borked.thing! : { name: 'Correct'; id: string; } ->borked.thing : { name: 'Correct'; id: string; } +>borked.thing : { name: 'Correct'; id: string; } | undefined >borked : BorkedType ->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 index f806124697868..a4563f46afef1 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -2,6 +2,13 @@ // @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" } @@ -20,165 +27,67 @@ type Large = | { type: "10" } | undefined; -// Small union: fallback discriminant narrowing path. +// 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 smallEqualRight: Small; -if ("1" === smallEqualRight!.type) { - smallEqualRight.type; -} - -declare const one: "1"; -declare let smallEqualIdentifier: Small; -if (smallEqualIdentifier!.type === one) { - smallEqualIdentifier.type; +declare let largeEqual: Large; +if (largeEqual!.type === "1") { + // @ts-expect-error + largeEqual.type; +} else { + // @ts-expect-error + largeEqual.type; } -declare let signedEqual: { type: -1 } | { type: 1 } | undefined; -if (signedEqual!.type === -1) { - signedEqual.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; } -// Large union: optimized discriminant narrowing path. - -declare let largeEqual: Large; -if (largeEqual!.type === "1") { - largeEqual.type; -} else { - largeEqual.type; -} - -declare let largeNotEqual: Large; -if (largeNotEqual!.type !== "1") { - largeNotEqual.type; -} - declare let largeSwitch: Large; switch (largeSwitch!.type) { case "1": + // @ts-expect-error largeSwitch.type; break; } -// Ordinary optional chaining still narrows a nullable large union through the fallback path. +// 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; +} declare let largeOptional: Large; if (largeOptional?.type === "1") { largeOptional.type; } - -// Optional chaining through a non-null assertion preserves nullable non-matching branches. - -declare let optionalBangLeft: Small; -if (optionalBangLeft!?.type === "1") { - optionalBangLeft.type; -} else { - // @ts-expect-error - optionalBangLeft.type; -} - -declare let optionalBangRight: Small; -if ("1" === optionalBangRight!?.type) { - optionalBangRight.type; -} else { - // @ts-expect-error - optionalBangRight.type; -} - -declare let optionalBangSwitch: Small; -switch (optionalBangSwitch!?.type) { - case "1": - optionalBangSwitch.type; - break; - default: - // @ts-expect-error - optionalBangSwitch.type; - break; -} - -// A non-null assertion captured by an alias does not apply after the source is reassigned. - -declare let maybeUndefined: Small; -declare let largeAliasedValue: Large; -declare let maybeLargeUndefined: Large; -const largeAliasedTag = largeAliasedValue!.type; - -largeAliasedValue = maybeLargeUndefined; - -if (largeAliasedTag === "1") { - // @ts-expect-error - largeAliasedValue.type; -} - -// Later-evaluated expressions with possible side effects do not carry the non-null fact. - -declare let assignmentInRight: Large; -if (assignmentInRight!.type === (assignmentInRight = maybeLargeUndefined, "1")) { - // @ts-expect-error - assignmentInRight.type; -} - -declare let assignmentWrappedAccessLeft: Small; -if ((assignmentWrappedAccessLeft!.type = (assignmentWrappedAccessLeft = maybeUndefined, "1")) === "1") { - // @ts-expect-error - assignmentWrappedAccessLeft.type; -} - -declare let assignmentWrappedAccessRight: Small; -if ("1" === (assignmentWrappedAccessRight!.type = (assignmentWrappedAccessRight = maybeUndefined, "1"))) { - // @ts-expect-error - assignmentWrappedAccessRight.type; -} - -declare let looseEquality: Small; -const coercibleValue: any = { - valueOf() { - looseEquality = maybeUndefined; - return "1"; - }, -}; -if (looseEquality!.type == coercibleValue) { - // @ts-expect-error - looseEquality.type; -} - -declare let computedKeyLeft: Small; -enum ComputedKeyLeft { - type = "type", -} -Object.defineProperty(ComputedKeyLeft, "type", { - get() { - computedKeyLeft = maybeUndefined; - return "type" as const; - }, -}); -if (computedKeyLeft![ComputedKeyLeft.type] === "1") { - // @ts-expect-error - computedKeyLeft.type; -} - -declare let assignmentInCase: Large; -switch (assignmentInCase!.type) { - case (assignmentInCase = maybeLargeUndefined, "1"): - // @ts-expect-error - assignmentInCase.type; - break; -} From 25b6377b625de89d63b5f103387b376315b7aba0 Mon Sep 17 00:00:00 2001 From: z0rimo <46391780+z0rimo@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:11:09 +0900 Subject: [PATCH 12/13] Fix nullable discriminant fast-path consistency --- tsc/internal/checker/flow.go | 15 ++++++++---- ...iscriminatedUnionNonNullAccessNarrowing.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 63341a0cf0e70..3de5b661c1e51 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -700,14 +700,14 @@ func (c *Checker) narrowTypeByTypeFacts(t *Type, impliedType *Type, facts TypeFa } func (c *Checker) narrowTypeByDiscriminantProperty(t *Type, access *ast.Node, operator ast.Kind, value *ast.Node, assumeTrue bool) *Type { - if (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && t.flags&TypeFlagsUnion != 0 && (!c.strictNullChecks || !c.maybeTypeOfKind(t, TypeFlagsNullable)) { + if (operator == ast.KindEqualsEqualsEqualsToken || operator == ast.KindExclamationEqualsEqualsToken) && t.flags&TypeFlagsUnion != 0 { keyPropertyName := c.getKeyPropertyName(t) if keyPropertyName != "" { if accessedName, ok := c.getAccessedPropertyName(access); ok && keyPropertyName == accessedName { 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 { @@ -1229,7 +1236,7 @@ func (c *Checker) narrowTypeBySwitchOptionalChainContainment(t *Type, data *ast. } func (c *Checker) narrowTypeBySwitchOnDiscriminantProperty(t *Type, access *ast.Node, data *ast.FlowSwitchClauseData) *Type { - if data.ClauseStart < data.ClauseEnd && t.flags&TypeFlagsUnion != 0 && (!c.strictNullChecks || !c.maybeTypeOfKind(t, TypeFlagsNullable)) { + if data.ClauseStart < data.ClauseEnd && t.flags&TypeFlagsUnion != 0 { accessedName, _ := c.getAccessedPropertyName(access) if accessedName != "" && c.getKeyPropertyName(t) == accessedName { clauseTypes := c.getSwitchClauseTypes(data.SwitchStatement)[data.ClauseStart:data.ClauseEnd] @@ -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/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index a4563f46afef1..3483b55208732 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -27,6 +27,8 @@ type Large = | { type: "10" } | undefined; +type LargeWithNull = Exclude | null; + // Strict equality, both branches. declare let smallEqual: Small; @@ -79,15 +81,37 @@ switch (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; } From 89fb8e8e9170a257ff2b60d72c5d8f92c94e5e5c Mon Sep 17 00:00:00 2001 From: z0rimo <46391780+z0rimo@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:04:46 +0900 Subject: [PATCH 13/13] Narrow discriminant fix to reported regression --- tsc/internal/checker/flow.go | 12 +-- ...iscriminatedUnionNonNullAccessNarrowing.ts | 96 ++----------------- 2 files changed, 10 insertions(+), 98 deletions(-) diff --git a/tsc/internal/checker/flow.go b/tsc/internal/checker/flow.go index 3de5b661c1e51..342fac860f594 100644 --- a/tsc/internal/checker/flow.go +++ b/tsc/internal/checker/flow.go @@ -707,7 +707,8 @@ 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 c.addNullableConstituents(candidate, t) + // Nullable constituents have no key property; preserve them as the general path does. + return c.getUnionType([]*Type{candidate, c.filterType(t, func(t *Type) bool { return t.flags&TypeFlagsNullable != 0 })}) } if propType := c.getTypeOfPropertyOfType(candidate, keyPropertyName); propType != nil && isUnitType(propType) { return c.removeType(t, candidate) @@ -722,13 +723,6 @@ 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 { @@ -1248,7 +1242,7 @@ func (c *Checker) narrowTypeBySwitchOnDiscriminantProperty(t *Type, access *ast. return c.unknownType })) if candidate != c.unknownType { - return c.addNullableConstituents(candidate, t) + return candidate } } } diff --git a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts index 3483b55208732..578401a41174a 100644 --- a/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts +++ b/tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts @@ -2,12 +2,7 @@ // @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. +// Discriminant narrowing should be consistent regardless of union size (#62511). type Small = | { type: "1" } @@ -27,91 +22,14 @@ type Large = | { 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 { +declare let small: Small; +if (small!.type === "1") { // @ts-expect-error - smallOptional.type; + small.type; } -declare let largeOptional: Large; -if (largeOptional?.type === "1") { - largeOptional.type; -} else { +declare let large: Large; +if (large!.type === "1") { // @ts-expect-error - largeOptional.type; + large.type; }