Skip to content

Fix spread operator failing to distribute over union when type is inlined#62836

Closed
thromel wants to merge 1 commit intomicrosoft:mainfrom
thromel:fix/spread-tuple-union-distribution
Closed

Fix spread operator failing to distribute over union when type is inlined#62836
thromel wants to merge 1 commit intomicrosoft:mainfrom
thromel:fix/spread-tuple-union-distribution

Conversation

@thromel
Copy link

@thromel thromel commented Dec 4, 2025

Fixes #62812

Problem

When a union/intersection type like number | string appears in the true branch of a conditional type that has the same type as its check type, the type was incorrectly being narrowed with a substitution constraint. This caused issues when the union type was used as a type argument to a generic type.

For example:

type CrossProduct<Union, Counter extends unknown[]> =
    Counter extends [infer Zero, ...infer Rest]
    ? (Union extends infer Member
        ? [Member, ...CrossProduct<Union, Rest>]
        : never)
    : [];

type Depth1 = CrossProduct<number | string, [undefined]> // [string] | [number]

// This works correctly:
let test2: (number | string extends infer Union ? (Union extends unknown ? [Union, ...Depth1]: never) : never);
// Result: [string, string] | [number, number] | [string, number] | [number, string]

// But this was broken (inlined instead of aliased):
let test3: (number | string extends infer Union ? (Union extends unknown ? [Union, ...CrossProduct<number | string, [undefined]>]: never) : never);
// Expected: [string, string] | [number, number] | [string, number] | [number, string]
// Actual: [string, string] | [number, number]  (missing cross-product entries)

Root Cause

In getConditionalFlowTypeOfType, when processing a type node in the true branch of a conditional type, the function checks if the type matches the conditional's check type and creates a substitution type with the implied constraint.

The issue is that for structural types like number | string, different occurrences in the code all resolve to the same canonical type. So when CrossProduct<number | string, [undefined]> appeared inside the true branch of number | string extends infer Union ? ..., the type argument number | string was incorrectly being narrowed with the constraint Union even though it was a completely independent occurrence.

Fix

Added a check to skip this narrowing for union/intersection types that don't contain type variables:

const isStructuralTypeWithoutTypeVariables = !!(type.flags & TypeFlags.UnionOrIntersection) && !couldContainTypeVariables(type);
if (!isStructuralTypeWithoutTypeVariables && (covariant || type.flags & TypeFlags.TypeVariable) && ...) {

This ensures:

  • Named types (interfaces, classes, type aliases) still get narrowed - different references to the same named type refer to the same entity
  • Structural types (unions, intersections) without type variables are NOT narrowed - different occurrences are independent even if they structurally match

Test

Added tests/cases/conformance/types/spread/spreadTupleUnionDistribution.ts to verify the fix.

@github-project-automation github-project-automation bot moved this to Not started in PR Backlog Dec 4, 2025
@typescript-bot typescript-bot added the For Backlog Bug PRs that fix a backlog bug label Dec 4, 2025
@thromel
Copy link
Author

thromel commented Dec 4, 2025

@microsoft-github-policy-service agree

@thromel
Copy link
Author

thromel commented Dec 10, 2025

@RyanCavanaugh

…ined

Fixes microsoft#62812

When a union/intersection type like `number | string` appears in the true
branch of a conditional type that has the same type as its check type,
the type was incorrectly being narrowed with a substitution constraint.
This caused issues when the union type was used as a type argument to
a generic type - different occurrences of `number | string` in the code
were being treated as the same entity when they should be independent.

The fix adds a check to skip this narrowing for structural types
(unions/intersections) that don't contain type variables. Named types
(interfaces, classes, etc.) still get narrowed since different references
to the same named type refer to the same entity.
@thromel thromel force-pushed the fix/spread-tuple-union-distribution branch from f44ce00 to 8d6b841 Compare December 15, 2025 18:13
@github-project-automation github-project-automation bot moved this from Not started to Done in PR Backlog Mar 24, 2026
@typescript-bot
Copy link
Collaborator

With 6.0 out as the final release vehicle for this codebase, we're closing all PRs that don't fit the merge criteria for post-6.0 patches. If you think this was a mistake and this PR fits the post-6.0 patch criteria, please post to the 6.0 iteration issue with details (specifically, which PR and which patch criteria it satisfies).

Next steps for PRs:

  • For crash bugfixes or language service improvements, PRs are currently accepted at the typescript-go repo
  • Changes to type system behavior should wait until after 7.0, at which point mainline TypeScript development will resume in this repository with the Go codebase
  • Library file updates (lib.d.ts etc) continue to live in this repo or the DOM Generator repo as appropriate

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: Done

Development

Successfully merging this pull request may close these issues.

Spread operator fails to distribute over union when recursive type call is inlined instead of aliased

3 participants