🔎 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:
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.
- 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.
- 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.
🔎 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
⏯ Playground Link
The Playground doesn't reproduce this. The slow path depends on declarations living in a
.d.tsthat is only resolved on demand underskipLibCheck. Repro repo: https://github.com/ctriley/ts7-union-object-literal-type-explosion💻 Code
src/plugin.d.ts(the repro generates 24propNmembers; 3 are shown here):src/index.ts:tsconfig.json:strict,skipLibCheck,noEmit,include: ["src"].The repo has two variants, each with a lockfile and a
run.shthat checks with bothtypescript@6.0.3andtypescript@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({}))fromplatejs@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/:plate/(first NBase*Plugins):In our ~12k-file Next.js app, this one call made
tsc --noEmittake 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:
configure({}),configure({ key: undefined })configure(empty), whereconst empty = {}configure({} as {})/({} as any)/(null!)/(() => ({}))🙂 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
--pprofDirCPU profile:plugin.configureon a union of N plugin types combines the N signatures, intersecting their parameter types:(F1 | O1) & (F2 | O2) & …, whereFiis the callback type andOiis 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.checkObjectLiteralcallsgetApparentTypeOfContextualTypeand thendiscriminateContextualTypeByObjectMembers, which callsgetPropertiesOfType(contextualType).getPropertiesOfUnionOrIntersectionTypeenumerates the properties of the first union constituent only. For each property name,createUnionOrIntersectionPropertycallsgetTypeOfSymbolon that property in every constituent. Each constituent is an intersection, so each call creates a new intersection type.F1 & F2 & …comes first. It has no properties, so step 2 does nothing.CompareTypes): aliased or named types sort before anonymous ones, and intersections sort by their constituent lists. The all-config intersectionO1 & 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:
The ordering, not a logic difference, explains the gap. If
src/plugin.d.tsbecomes a checkedsrc/plugin.ts, 6.0 resolvesConfig<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
discriminateContextualTypeByObjectMemberswhen 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.