Skip to content

Fix non-null discriminant narrowing consistency - #64257

Open
S.H Jeong (z0rimo) wants to merge 10 commits into
microsoft:mainfrom
z0rimo:fix/discriminated-union-nullability-62511
Open

S.H Jeong (z0rimo) wants to merge 10 commits into
microsoft:mainfrom
z0rimo:fix/discriminated-union-nullability-62511

Conversation

@z0rimo

@z0rimo S.H Jeong (z0rimo) commented Sep 14, 2026

Copy link
Copy Markdown

Fixes #62511

This fixes inconsistent discriminant narrowing when a discriminant property is accessed through a non-null assertion.

For example:

if (value!.type === "foo") {
    value.type;
}

Small and large discriminated unions previously handled this pattern differently because they could take different narrowing paths.

The checker now carries the non-null fact into discriminant narrowing for direct accesses where it can be preserved safely. Otherwise, it conservatively uses the existing narrowing path. The optimized discriminant path is also avoided while the current flow type remains nullable, keeping the result consistent across union sizes without changing the optimization threshold.

The change updates the existing narrowingUnionWithBang baseline and adds focused regression coverage for equality, inequality, switch narrowing, optional-chain preservation, and evaluation-order safety.

AI assistance was used during investigation and implementation. I reviewed the patch, traced the checker behavior, and verified the change with focused compiler tests and the repository's validation commands locally.

Copilot AI balanced review requested due to automatic review settings September 14, 2026 01:55
@github-project-automation github-project-automation Bot moved this to Not started in PR Backlog Sep 14, 2026
@typescript-automation typescript-automation Bot added the For Backlog Bug PRs that fix a backlog bug label Sep 14, 2026
@z0rimo

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The implementation remains inconsistent for switch narrowing and can incorrectly narrow mutable values through aliased discriminants.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Fixes inconsistent nullability during discriminant narrowing after non-null assertions.

Changes:

  • Applies non-null facts before equality-based discriminant narrowing.
  • Adds small/large union regression tests.
  • Updates the affected type baseline.
File summaries
File Description
tsc/internal/checker/flow.go Normalizes nullable types before narrowing.
tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts Adds regression coverage.
tsc/testdata/baselines/reference/compiler/narrowingUnionWithBang.types Records corrected inferred types.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread tsc/internal/checker/flow.go Outdated
Comment thread tsc/internal/checker/flow.go Outdated
@z0rimo
S.H Jeong (z0rimo) marked this pull request as draft September 14, 2026 03:33
@z0rimo
S.H Jeong (z0rimo) marked this pull request as ready for review September 14, 2026 03:34
@z0rimo
S.H Jeong (z0rimo) requested a balanced review from Copilot September 14, 2026 03:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Combined non-null and optional-chain accesses incorrectly remove nullability from false and default branches.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

tsc/internal/checker/flow.go:505

  • The mirrored right-hand form ("1" === value!?.type) has the same issue: optional chaining can complete with an undefined base, but this treats the base as non-null in both branches. Gate the eager non-null facts on the access not being an optional chain.
			if rightAccess == right && c.strictNullChecks && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) {

tsc/internal/checker/flow.go:1091

  • For switch (value!?.type), the default path can be reached because value is nullish. This condition nevertheless removes nullability for every switch clause because an optional access whose receiver is a non-null expression also satisfies isNonNullAccess. Exclude optional chains here so the existing switch optional-chain containment logic decides which clauses imply presence.
			if access == expr && c.strictNullChecks && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) {
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go Outdated
@z0rimo
S.H Jeong (z0rimo) marked this pull request as draft September 14, 2026 05:03
@z0rimo
S.H Jeong (z0rimo) marked this pull request as ready for review September 14, 2026 05:15
@z0rimo
S.H Jeong (z0rimo) requested a balanced review from Copilot September 14, 2026 05:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Eager non-null facts can become stale after assignments in later operands or switch case expressions.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

tsc/internal/checker/flow.go:1093

  • A switch discriminant is evaluated before its case expressions, and those expressions may reassign the matched reference. For example, switch (value!.type) { case (value = maybeUndefined, "1"): value.type; } can enter the case with value undefined, while this unconditional normalization drops that possibility. Account for writes from evaluated case labels before applying the eager fact (or restrict it to labels that cannot write the reference), and cover this ordering case in the regression test.
			if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && c.maybeTypeOfKind(t, TypeFlagsNullable) {
				t = c.getTypeWithFacts(t, TypeFactsNEUndefinedOrNull)
			}
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go Outdated
@z0rimo
S.H Jeong (z0rimo) marked this pull request as draft September 14, 2026 05:31
@z0rimo
S.H Jeong (z0rimo) marked this pull request as ready for review September 14, 2026 05:41
@z0rimo
S.H Jeong (z0rimo) requested a balanced review from Copilot September 14, 2026 05:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Prefix assignments can invalidate dotted references while the new logic still applies an unsound non-null fact.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go Outdated
@z0rimo
S.H Jeong (z0rimo) marked this pull request as draft September 14, 2026 05:47
@z0rimo
S.H Jeong (z0rimo) marked this pull request as ready for review September 14, 2026 05:58
@z0rimo
S.H Jeong (z0rimo) requested a balanced review from Copilot September 14, 2026 05:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Mutation scanning misses assertion-wrapped assignments and incorrectly scans deferred function bodies.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

tsc/internal/checker/flow.go:1862

  • Assignments whose target is wrapped in a type assertion are missed by this match. TypeScript permits (value as Small | undefined) = maybeUndefined; IsAssignmentTarget is true for the outer AsExpression, but isOrContainsMatchingReference does not unwrap that target. Consequently value!.type === ((value as Small | undefined) = maybeUndefined, "1") can remove undefined even though the true branch may run after assigning undefined. Please normalize assertion-wrapped assignment targets (including angle-bracket assertions) before matching and cover this form in the evaluation-order tests.
    tsc/internal/checker/flow.go:1865
  • This recursively scans deferred function bodies, so merely creating a closure on the right-hand side suppresses the new narrowing. For example, if (value!.type === (() => { value = undefined; }, "1")) { value.type } reports value as possibly undefined even though the assignment is never evaluated. Please traverse only code evaluated as part of the operand/case expression (while still handling invoked functions such as IIFEs) and add a regression case.
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Parenthesized assertions and synchronous assignments inside async IIFEs remain incorrectly handled.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tsc/internal/checker/flow.go:498

  • Parenthesizing the assertion still prevents the fix: for if ((value!).type === "1") value.type, isNonNullAccess returns false because it only recognizes an immediate NonNullExpression (tsc/internal/checker/utilities.go:1092-1094). The optimized path is then skipped for the still-nullable type and the fallback preserves undefined, even though parentheses are semantically transparent. Normalize parentheses around the access base in isNonNullAccess and add equality/switch coverage for this spelling.
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Evaluation tracking mishandles accessors and class fields and misses generator and inherited-constructor mutations.

Review details

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

tsc/internal/checker/flow.go:1869

  • Accessor bodies are traversed as if they run when their containing object or class is created. An unrelated getter such as ({ get unused() { value = undefined; }, tag: "1" }).tag is never invoked, but it still suppresses the new non-null fact and leaves value erroneously nullable in the branch. Defer accessor bodies like other functions, then inspect only the matching getter/setter when an access or assignment actually invokes it.

This issue also appears in the following locations of the same file:

  • line 1876
  • line 1924
  • line 1933

tsc/internal/checker/flow.go:1878

  • The generic child traversal treats every class field initializer as immediately evaluated. For (class { field = (value = undefined); static tag = "1" }).tag, the instance field never runs because the class is not instantiated, yet this reports a matching assignment and prevents valid non-null narrowing. Class traversal needs to distinguish static initialization from instance initialization, visiting instance fields only when the inline class is constructed.
	return node.ForEachChild(func(child *ast.Node) bool {
		return c.containsMatchingAssignment(reference, child)
	})

tsc/internal/checker/flow.go:1925

  • Skipping a generator body is sound only for the call that creates its iterator. A subsequent inline .next() is evaluated before the comparison, but forEachInvokedFunction does not resolve that operation, so (function* () { value = replacement; })().next() can mutate value while this analysis still applies the stale non-null fact. Recognize iterator execution on an immediately produced generator and conservatively inspect its body.
	// Calling a generator evaluates its parameters but defers its body until the iterator advances.
	return ast.GetFunctionFlags(node)&ast.FunctionFlagsGenerator == 0 && node.Body() != nil && c.containsMatchingAssignment(reference, node.Body())

tsc/internal/checker/flow.go:1937

  • Construction only visits constructors declared directly on the callee class. For new (class extends class { constructor() { value = replacement; } })(), the implicit derived constructor invokes the inline base constructor, but that body is deferred during normal traversal and never revisited; the branch can therefore receive a stale non-null fact after value becomes undefined. Follow inline extends chains when resolving construction.
	case ast.IsClassExpression(callee):
		if construct {
			return core.Some(callee.Members(), func(member *ast.Node) bool {
				return ast.IsConstructorDeclaration(member) && visit(member)
			})
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Assignment-wrapped operands and loose equality can still propagate stale non-null facts.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

tsc/internal/checker/flow.go:507

  • The right-hand candidate has the same assignment-wrapper hole: getReferenceCandidate can return the assignment LHS even though the assignment RHS runs after that access, and loose equality may perform coercion afterward. Thus "1" === (value!.type = (value = maybeUndefined, "1")) can incorrectly make value non-null in the branch. Only apply the fact when the original right operand is the direct access and no coercing operator remains.
			if rightAccess == right && c.strictNullChecks && !ast.IsOptionalChain(rightAccess) && isNonNullAccess(rightAccess) && c.maybeTypeOfKind(t, TypeFlagsNullable) {
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Computed discriminant keys can mutate the reference and make the new eager non-null facts stale.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

tsc/internal/checker/flow.go:1093

  • The switch expression itself can still perform work after evaluating the non-null base. With switch (value![Keys.type]), an enum/const entity key is accepted as a discriminant, and a getter installed on Keys.type can set value to undefined while returning "type"; a matching case then enters with the stale non-null fact. The clause-expression check does not cover this evaluation. Require computed key evaluation in access to be side-effect-free (or only propagate for non-computed/literal-key accesses) before removing nullable constituents.
			if access == expr && c.strictNullChecks && !ast.IsOptionalChain(access) && isNonNullAccess(access) && switchClauseExpressionsAreSideEffectFree(data) && c.maybeTypeOfKind(t, TypeFlagsNullable) {
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/internal/checker/flow.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The evaluation-order tests use small unions and therefore do not exercise the optimized path responsible for the stale-fact regression.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts:204

  • The switch evaluation-order regression is only exercised with Small, whose fallback narrowing was already conservative. Exercise this with Large so the test actually verifies that the optimized discriminant path no longer propagates the stale non-null fact through a mutating case expression.
declare let assignmentInCase: Small;
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tsc/testdata/tests/cases/compiler/discriminatedUnionNonNullAccessNarrowing.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Signed numeric and bigint discriminants remain incorrectly excluded from otherwise safe non-null narrowing.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tsc/internal/checker/flow.go:1865

  • Signed numeric and bigint discriminants are incorrectly treated as side-effectful. For example, if (value!.type === -1) { value.type } and case -1 still retain undefined, because -1 is a PrefixUnaryExpression; with the new nullable guard, both paths fall back without applying the eager fact. Reuse the existing primitive-literal predicate, which includes side-effect-free signed literals, and add a signed-discriminant regression case.
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation addresses union-size consistency and evaluation-order invalidation with comprehensive focused coverage.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@z0rimo
S.H Jeong (z0rimo) marked this pull request as ready for review September 14, 2026 12:30
@z0rimo S.H Jeong (z0rimo) changed the title Fix non-null discriminant narrowing consistency and evaluation order Fix non-null discriminant narrowing consistency Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Backlog Bug PRs that fix a backlog bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

Discriminated union with 11+ items does not handle nullability same as union with <10 items

2 participants