Skip to content

[7.0 regression] Object literal passed to a method on a large union creates exponentially many types (type order dependent) #64692

Description

@ctriley

🔎 Search Terms

7.0 regression, performance, out of memory, union signatures, object literal contextual type, discriminateContextualTypeByObjectMembers, getPropertiesOfUnionOrIntersectionType, union ordering, type order dependent, cross product intersection

🕗 Version & Regression Information

  • This changed between versions 6.0.3 and 7.0.2

⏯ Playground Link

The Playground doesn't reproduce this. The slow path depends on declarations living in a .d.ts that is only resolved on demand under skipLibCheck. Repro repo: https://github.com/ctriley/ts7-union-object-literal-type-explosion

💻 Code

src/plugin.d.ts (the repro generates 24 propN members; 3 are shown here):

export type Shape<K extends string> = {
  key: K;
  prop0: { handler?: (key: K) => void };
  prop1: { handler?: (key: K) => void };
  prop2: { handler?: (key: K) => void };
  // ... prop23
};
export type Config<K extends string> = Omit<Partial<Shape<K>>, "configure">;
export type Plugin<K extends string> = Shape<K> & {
  configure: (config: ((ctx: { key: K }) => Config<K>) | Config<K>) => Plugin<K>;
};
export declare function createPlugin<K extends string>(key: K): Plugin<K>;

src/index.ts:

import { createPlugin } from "./plugin";

const p0 = createPlugin("p0");
const p1 = createPlugin("p1");
// ... p15

export const configured = [p0, p1, /* ... */ p15].map((plugin) => plugin.configure({}));

tsconfig.json: strict, skipLibCheck, noEmit, include: ["src"].

The repo has two variants, each with a lockfile and a run.sh that checks with both typescript@6.0.3 and typescript@7.0.2:

  • standalone/: the code above, with no dependencies (./run.sh <plugins> <properties>).
  • plate/: the real-world trigger, [BaseH1Plugin, ..., BaseImagePlugin].map((plugin) => plugin.configure({})) from platejs@53.

🙁 Actual behavior

TS 7.0.2 creates far more types and uses much more memory than 6.0.3. Instantiation counts are almost identical, so the extra types are unions and intersections. Both runs use --extendedDiagnostics, and TS 7 runs with --checkers 1.

standalone/:

plugins × properties 6.0.3 types 7.0.2 types 6.0.3 max RSS 7.0.2 max RSS
12 × 24 12,338 205,205 0.30 GB 0.18 GB
14 × 24 38,244 819,643 0.56 GB 0.72 GB
16 × 24 138,070 3,277,281 1.5 GB 2.8 GB
16 × 48 144,646 6,423,009 2.7 GB 5.4 GB

plate/ (first N Base*Plugins):

plugins 6.0.3 types 7.0.2 types 6.0.3 check 7.0.2 check
12 24,397 108,587 2.6 s 1.1 s
14 31,351 667,199 4.5 s 7.4 s
16 56,833 5,262,959 13.6 s 54.2 s (2.9 GB)
18 180,729 did not finish in 300 s 68.2 s > 300 s

In our ~12k-file Next.js app, this one call made tsc --noEmit take 153 s and 49.7 GB RSS on 7.0.2, against 58 s and 5.4 GB on 6.0.3. With --checkers 1, 7.0.2 created 46.6M types against 6.0.3's 1.77M. Typing the callback parameter ((plugin: AnySlatePlugin) =>) brings 7.0.2 back to 1.76M types.

Only the object-literal argument is affected. In the 14-plugin Plate case:

argument 6.0.3 types 7.0.2 types
configure({}), configure({ key: undefined }) 31K 667K
configure(empty), where const empty = {} 31K 31.5K
configure({} as {}) / ({} as any) / (null!) / (() => ({})) 29–31K 29–31K

🙂 Expected behavior

7.0 should create about as many types as 6.0 here, or at least not grow faster with the number of union members. The input program is the same, and only the order of union constituents changed between versions.

Additional information about the issue

What I think happens, from reading the 7.0 checker and a --pprofDir CPU profile:

  1. plugin.configure on a union of N plugin types combines the N signatures, intersecting their parameter types: (F1 | O1) & (F2 | O2) & …, where Fi is the callback type and Oi is the config type. That normalizes to a union of 2^N intersections. 6.0 and 7.0 both do this, and both hit TS2590 when N is 18 or more distinct types.
  2. For an object-literal argument, checkObjectLiteral calls getApparentTypeOfContextualType and then discriminateContextualTypeByObjectMembers, which calls getPropertiesOfType(contextualType). getPropertiesOfUnionOrIntersectionType enumerates the properties of the first union constituent only. For each property name, createUnionOrIntersectionProperty calls getTypeOfSymbol on that property in every constituent. Each constituent is an intersection, so each call creates a new intersection type.
  3. Which constituent comes first decides the cost:
    • 6.0 orders constituents by type id. Here the all-callback intersection F1 & F2 & … comes first. It has no properties, so step 2 does nothing.
    • 7.0 orders them structurally (CompareTypes): aliased or named types sort before anonymous ones, and intersections sort by their constituent lists. The all-config intersection O1 & O2 & … therefore comes first, and every property is resolved across all 2^N constituents.

In the 14-plugin Plate repro, the profile attributes 65% of check time to this path:

checkObjectLiteral → getApparentTypeOfContextualType → discriminateContextualTypeByObjectMembers → getPropertiesOfType → getPropertiesOfUnionOrIntersectionType → createUnionOrIntersectionProperty → getTypeOfSymbol → getIntersectionType → getCrossProductIntersections

The ordering, not a logic difference, explains the gap. If src/plugin.d.ts becomes a checked src/plugin.ts, 6.0 resolves Config<K> first while checking the alias declaration. Its first constituent is then the config intersection, and 6.0 creates as many types as 7.0: 205,225 vs 205,251 at 12 × 24. So 6.0 has the same exponential behavior and avoided it only because of the order it created types in.

Possible directions: skip or bound the optional-member discrimination in discriminateContextualTypeByObjectMembers when the contextual union is very large. Alternatively, avoid resolving union properties in a way that depends on which constituent sorts first.

Possibly related: #63749, another 7.0 issue that depends on type order.

Metadata

Metadata

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions