Skip to content

Deduplicate repeated declarations on union/intersection properties#63119

Open
Andarist wants to merge 1 commit intomicrosoft:mainfrom
Andarist:fix/duplicated-declarations
Open

Deduplicate repeated declarations on union/intersection properties#63119
Andarist wants to merge 1 commit intomicrosoft:mainfrom
Andarist:fix/duplicated-declarations

Conversation

@Andarist
Copy link
Contributor

@Andarist Andarist commented Feb 8, 2026

Fixes an OOM issue caused by a double cross-product of union/intersection types.

Problem

Certain patterns involving union types with cross-product intersections cause createUnionOrIntersectionProperty to accumulate an enormous number of duplicate declaration references on synthetic union/intersection property symbols. With just 3 shapes, a single property w accumulates 1,611 declarations from only 4 unique declaration nodes. Growth is N⁴ in the number of shapes — the original tldraw repro (16 shapes) OOMs: tldraw/tldraw#7755

Root cause

Three mechanisms combine to produce multiplicative growth:

1. Cross-product explosion in getIntersectionType

ShapePartialWithDimensions<T> references TLShapePartial<ShapeWithDimensions<T>> twice — once directly, once via an indexed access ["props"]:

type ShapePartialWithDimensions<T extends TLShape> =
    TLShapePartial<ShapeWithDimensions<T>>                                    // ← reference 1
  & { props: TLShapePartial<ShapeWithDimensions<T>>["props"] & Dimensions }   // ← reference 2

These resolve independently over the shape union. When their props types are intersected, getIntersectionType distributes over both unions, producing N×N members (3 shapes → 9 members).

2. Nested cross-product in getNarrowableTypeForReference

Each of the 9 members still contains an unresolved indexed access TLShapePartial<ShapeWithDimensions<N>>["props"] referencing the generic type parameter. getNarrowableTypeForReference resolves these by substituting N's constraint, which involves looking up props on ShapeWithDimensions<N>:

type ShapeWithDimensions<T extends TLShape> = T & { props: T["props"] & Dimensions }
//                                            ^          ^
//                                        ref to T    ref to T["props"]

ShapeWithDimensions<T> has the same structure — two independent references to T through props. When the constraint (3-shape union) is substituted, T.props → UNION(3) and T["props"] → UNION(3) get intersected → another 3×3 cross-product. So each of the 9 members expands to 9 (not 3), giving 9×9 = 81 total members (N² × N² = N⁴).

3. addRange(declarations, prop.declarations) without deduplication

In createUnionOrIntersectionProperty, declarations from each constituent property are concatenated via addRange without deduplication:

for (const prop of props) {
    declarations = addRange(declarations, prop.declarations);
}

When prop is a synthetic property from a prior createUnionOrIntersectionProperty call, its .declarations is already an accumulated array from that earlier resolution. Through nested union/intersection resolution, the same few declaration nodes get concatenated over and over, with each level multiplying the count while the unique set stays constant.

Fix

Deduplicate declarations in createUnionOrIntersectionProperty. This caps the declarations array at the actual unique count at every nesting level, preventing the entire cascade.

Walkthrough (3 shapes)

Consider 3 TLShape subtypes with distinct props, each containing property w, plus a Dimensions type — 4 unique declaration nodes total:

type TLShape = ShapeA | ShapeB | ShapeC
// ShapeA.props = { w: number; color: string }   ← decl dA
// ShapeB.props = { w: number; size: number }    ← decl dB
// ShapeC.props = { w: number; url: string }     ← decl dC
type Dimensions = { w: number; h: number }       // ← decl dD

The problematic type accesses props?.w on a generic constrained to a cross-product-producing intersection:

type ShapeWithDimensions<T extends TLShape> = T & { props: T["props"] & Dimensions }

type ShapePartialWithDimensions<T extends TLShape> =
    TLShapePartial<ShapeWithDimensions<T>>
//  ^ref1
      & { props: TLShapePartial<ShapeWithDimensions<T>>["props"] & Dimensions }
//               ^ref2 (indexed access)

function f<N extends ShapeWithDimensions<TLShape>>(val: ShapePartialWithDimensions<N>) {
    val.props?.w   // ← triggers the cascade
}

The two references to TLShapePartial<ShapeWithDimensions<T>> in ShapePartialWithDimensions distribute independently → 3×3 = 9 member cross-product (mechanism 1). Then getNarrowableTypeForReference resolves each member's unresolved indexed access through ShapeWithDimensions<N>, which has its own two independent references to N — producing another 3×3 cross-product per member → 9×9 = 81 total (mechanism 2). The declarations accumulate through mechanism 3:

Level What happens Total w decls Unique
0 Original w declarations on each type 1 each 1
1 Cross-product intersection members (Partial<Tᵢ_props & Dimensions> & Partial<Tⱼ_props & Dimensions> & Dimensions) 2–3 each 2–3
2 UNION(9) concatenates 9 constituent arrays 24 4
3 Constraint resolution creates intermediate unions 7–16 4
4 Outer intersections combine intermediates with Dimensions 8–33 4
5 UNION(81) concatenates all 81 constituent arrays 1,611 4

Each union level multiplies by its fan-out. Each intersection level sums its members' already-accumulated arrays. Unique count never changes — with deduplication, the total stays at 4 regardless of the number of shapes.

@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Feb 8, 2026
@github-project-automation github-project-automation bot moved this to Not started in PR Backlog Feb 8, 2026
@typescript-bot
Copy link
Collaborator

This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

Status: Not started

Development

Successfully merging this pull request may close these issues.

2 participants