Skip to content

fix(no-unnecessary-condition): don't flag required ?. on deferred conditional return types#1042

Open
pelly-ryu wants to merge 3 commits into
oxc-project:mainfrom
pelly-ryu:fix/no-unnecessary-condition-deferred-conditional
Open

fix(no-unnecessary-condition): don't flag required ?. on deferred conditional return types#1042
pelly-ryu wants to merge 3 commits into
oxc-project:mainfrom
pelly-ryu:fix/no-unnecessary-condition-deferred-conditional

Conversation

@pelly-ryu

Copy link
Copy Markdown

Generated with Claude Code, reviewed and tested by me.

A generic method returning a deferred conditional makes the chain below report a false "Unnecessary optional chain on a non-nullish value", even though b.get('a') is Box | null (tsc reports TS2531 without the ?.).

interface Box {
  get<H = never>(name: string): [H] extends [never] ? Box | null : Box;
}
declare const b: Box;
const r = b.get('a')?.get('b')?.get('c');

On a chain of 3+ links TypeScript leaves the inner conditional unresolved (TypeFlagsConditional), which carries no nullish flag, so isNullishType reads it as non-nullish. In isCallExpressionNullableOriginFromCallee that makes the callee return look non-nullable, the chain's | undefined is attributed to the optional-chain short-circuit, and removeNullishFromType drops the real | null. The same unresolved conditional also reaches the final nullish check in checkOptionalChain directly when the chain is property access whose type stays unresolved (a type parameter in scope).

A new isConstrainedNullishType replaces a conditional with its base constraint (the union of its branches) before the nullish test, recurses into union members, and is gated on TypeFlagsConditional so other types are unchanged. It is used at both nullishness checks the optional-chain path relies on: the callee-return check and the final check in checkOptionalChain. A deferred conditional whose branches are all non-nullish is still reported.

Adds 6 valid regression cases (| null/| undefined branch, explicit <never>, named alias, union-member conditional, property access in a generic scope) and an invalid precision case; the snapshot diff is additive.

pelly-ryu and others added 3 commits June 29, 2026 19:32
…onditional return types

On optional chains of length >= 3, TypeScript leaves a generic method's
conditional return type (`T extends U ? X : Y`) unresolved
(`TypeFlagsConditional`) instead of resolving it to a branch. A raw conditional
has no null/undefined flag and is not a union, so isNullishType reads it as
non-nullish even though its branch is `Box | null`.

This is misread at two nullishness checks on the optional-chain path. In
isCallExpressionNullableOriginFromCallee the callee return looks non-nullable,
so the chain's `| undefined` is attributed to the short-circuit and
removeNullishFromType drops the `| null`. The same unresolved conditional also
reaches the final nullish check in checkOptionalChain directly when the chain is
property access whose type stays unresolved (a type parameter in scope).

Fix: add isConstrainedNullishType, which replaces a conditional with its base
constraint (the union of its branches) before judging nullishness and recurses
through union members. Use it at both checks. The fold is gated on
`TypeFlagsConditional`, so other types are unchanged. A deferred conditional
whose branches are all non-nullish is still reported (added invalid test).

Tests: 6 valid cases (`| null`/`| undefined` branch, explicit `<never>`, named
alias, union-member conditional, property access in a generic scope) and 1
invalid precision case. Snapshot diff is additive (+16 / -0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant