Skip to content

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.

…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.
@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

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: Not started

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

2 participants