Fix non-null discriminant narrowing consistency - #64257
S.H Jeong (z0rimo) wants to merge 10 commits into
Conversation
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
🟡 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 becausevalueis nullish. This condition nevertheless removes nullability for every switch clause because an optional access whose receiver is a non-null expression also satisfiesisNonNullAccess. 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
There was a problem hiding this comment.
🟡 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 withvalueundefined, 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
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🔵 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;IsAssignmentTargetis true for the outerAsExpression, butisOrContainsMatchingReferencedoes not unwrap that target. Consequentlyvalue!.type === ((value as Small | undefined) = maybeUndefined, "1")can removeundefinedeven though the true branch may run after assigningundefined. 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 }reportsvalueas 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
There was a problem hiding this comment.
🟡 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,isNonNullAccessreturns false because it only recognizes an immediateNonNullExpression(tsc/internal/checker/utilities.go:1092-1094). The optimized path is then skipped for the still-nullable type and the fallback preservesundefined, even though parentheses are semantically transparent. Normalize parentheses around the access base inisNonNullAccessand add equality/switch coverage for this spelling.
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🔵 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" }).tagis never invoked, but it still suppresses the new non-null fact and leavesvalueerroneously 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, butforEachInvokedFunctiondoes not resolve that operation, so(function* () { value = replacement; })().next()can mutatevaluewhile 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 aftervaluebecomes undefined. Follow inlineextendschains 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
3e24e33 to
11434b3
Compare
There was a problem hiding this comment.
🟡 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:
getReferenceCandidatecan 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 makevaluenon-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
There was a problem hiding this comment.
🟡 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 onKeys.typecan setvalueto 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 inaccessto 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
There was a problem hiding this comment.
🟡 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 withLargeso 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
There was a problem hiding this comment.
🔵 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 }andcase -1still retainundefined, because-1is aPrefixUnaryExpression; 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
There was a problem hiding this comment.
🟢 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
Fixes #62511
This fixes inconsistent discriminant narrowing when a discriminant property is accessed through a non-null assertion.
For example:
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
narrowingUnionWithBangbaseline 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.