Skip to content

TS2871 'always nullish' false positive on binding destructured from .map over spread-of-union (regression 5.9.3 β†’ 6.0.3; also in 7.0.2)Β #63642

Description

@nejim123

πŸ”Ž Search Terms

TS2871 always nullish false positive destructured union spread ternary nullish coalescing

πŸ•— Version & Regression Information

  • This changed between versions 5.9.3 and 6.0.3
  • Also reproduces on 7.0.2 (native): identical diagnostic at the same position

πŸ’» Code

const make = (s: string | null) =>
  s === null
    ? { paymentIntentId: null, transferGroup: null }
    : { paymentIntentId: s as string | null, transferGroup: s as string | null }
const decorated = [null, 'pi_1'].map(s => ({ tag: s, ...make(s) }))
const localTxMap = new Map<string, string>()
const groupMap = new Map<string, string>()
const rows = decorated.map(({ paymentIntentId, transferGroup }) => ({
  localTransactionId: (paymentIntentId ? localTxMap.get(paymentIntentId) ?? null : null)
    ?? (transferGroup ? groupMap.get(transferGroup) ?? null : null),
}))
console.log(rows)

Compile with tsc --noEmit --strict --target es2022 repro.ts.

πŸ™ Actual behavior

repro.ts(9,24): error TS2871: This expression is always nullish.

6.0.3 and 7.0.2 report the parenthesized ternary (paymentIntentId ? localTxMap.get(paymentIntentId) ?? null : null) as always nullish. (On a larger variant of this code, the compiler reports two TS2871 diagnostics at adjacent columns β€” the ternary and the paymentIntentId identifier itself.)

This contradicts the compiler's own inference: paymentIntentId is declared string | null (probe: const x: 1 = paymentIntentId errors with Type 'string | null' is not assignable to type '1' on the same versions), so the true branch localTxMap.get(paymentIntentId) ?? null is string | null and the ternary is not always nullish. At runtime the element 'pi_1' takes the truthy branch and the lookup executes.

The trigger appears to be the binding destructured from a .map over elements built by spreading a union ({ paymentIntentId: null } | { paymentIntentId: string | null }, where one member is a subtype of the other): the always-nullish analysis seems to collapse the union to the all-null member while the declared type of the same binding remains string | null.

πŸ™‚ Expected behavior

No error, matching 5.9.3. The expression has type string | null and can be non-nullish.

Additional information about the issue

Workaround: hoisting the ternaries into named consts before the ?? chain silences the diagnostic on 6.0.3/7.0.2 without behavior change. Found while validating a TypeScript 7 migration on a production codebase (the original site is a Stripe payout-reconciliation mapper; this repro is a faithful reduction with no dependencies).

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugA bug in TypeScriptDomain: check: Control FlowThe issue relates to control flow analysis

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions