π 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).
π Search Terms
TS2871 always nullish false positive destructured union spread ternary nullish coalescing
π Version & Regression Information
π» Code
Compile with
tsc --noEmit --strict --target es2022 repro.ts.π Actual behavior
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 thepaymentIntentIdidentifier itself.)This contradicts the compiler's own inference:
paymentIntentIdis declaredstring | null(probe:const x: 1 = paymentIntentIderrors withType 'string | null' is not assignable to type '1'on the same versions), so the true branchlocalTxMap.get(paymentIntentId) ?? nullisstring | nulland 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
.mapover 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-nullmember while the declared type of the same binding remainsstring | null.π Expected behavior
No error, matching 5.9.3. The expression has type
string | nulland 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).