From 49b133751c5bce606eef37ec95d02e4349a8eb5c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:24:48 -0700 Subject: [PATCH] Adopt a contextual union's map arm for a bare new Map() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Map()` has no type arguments, and the no-argument constructor gives the checker nothing to infer them from: the expression types as Map however the destination spells it. Construction adopted a contextual type only when it mapped to a map, so a nullable or readonly annotation — `Map | undefined`, `Map | null`, `ReadonlyMap | undefined`, an optional record field — refused the very value its slot was built for (SC2003; pi's extension registry, whose `handlers?: Map<...>` fields start empty). A Map value can only inhabit a union's map arm, so construction now adopts that arm when the contextual union has exactly one: the remainder of the annotation is the nullish absence the value never is. Two different map arms stay refused (SC2003), because the layout would be ambiguous. Corpus 8528 pins the nullable, readonly and optional-field annotations, a parameter slot, iteration after the adoption, and remains byte-exact against Node. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-classes.ts | 21 ++++++- ...28-new-collection-contextual-union.ts.json | 6 ++ .../8528-new-collection-contextual-union.ts | 59 +++++++++++++++++++ ...28-new-collection-contextual-union.ts.json | 1 + 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json create mode 100644 tests/corpus/8528-new-collection-contextual-union.ts create mode 100644 tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 014bbc71a..487f221c8 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -10071,6 +10071,21 @@ function lowerProgramClassNew( }); } +/** The supported map layout a contextual type pins for `new Map()`: the type + * itself, or the single map arm of a union — a nullable annotation such as + * `Map | undefined`, or `ReadonlyMap | undefined`, where a Map + * value can only inhabit the map arm and the no-argument constructor gives + * the checker nothing to infer the type arguments from. */ +function contextualMapLayout(lowerer: Lowerer, ctx: ts.Type): IrType | null { + const mapped = lowerer.mapTypeOf(ctx); + if (mapped?.kind === "map") return mapped; + if (mapped?.kind !== "union") return null; + const arms = (lowerer.unions.get(mapped.unionId)?.arms ?? []).filter( + (arm): arm is IrType & { kind: "map" } => arm.kind === "map", + ); + return arms.length === 1 ? arms[0]! : null; +} + function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType | null { const parent = expr.parent; if ( @@ -11064,10 +11079,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { mapped = lowerer.mapTypeOf(inferred); } else if (mapped?.kind !== "map" || contextualSeed) { const ctx = lowerer.checker.getContextualType(expr); - const ctxMapped = ctx ? lowerer.mapTypeOf(ctx) : null; - if (ctx && ctxMapped?.kind === "map") { + const ctxMap = ctx ? contextualMapLayout(lowerer, ctx) : null; + if (ctx && ctxMap !== null) { tsType = ctx; - mapped = ctxMapped; + mapped = ctxMap; } } if (seedArg && !entriesLit && mapped?.kind === "map") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json new file mode 100644 index 000000000..abdf9b9f4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8528-new-collection-contextual-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8528-new-collection-contextual-union.ts" + ], + "diags": [] +} diff --git a/tests/corpus/8528-new-collection-contextual-union.ts b/tests/corpus/8528-new-collection-contextual-union.ts new file mode 100644 index 000000000..b5aecc244 --- /dev/null +++ b/tests/corpus/8528-new-collection-contextual-union.ts @@ -0,0 +1,59 @@ +// `new Map()` (no type arguments) in a contextual slot whose type is a +// union: `Map | undefined`, `ReadonlyMap | null`. A Map value +// can only inhabit the map arm, so the construction adopts that layout +// instead of the checker's bare Map. + +function lookup(handlers: Map | undefined, key: string): string { + if (handlers === undefined) return "absent"; + const found = handlers.get(key); + return found === undefined ? "missing" : String(found); +} + +const counts: Map | undefined = new Map(); +if (counts !== undefined) { + counts.set("a", 1); + counts.set("b", counts.size + 2); + console.log(counts.size, counts.get("a"), counts.get("b")); +} +console.log(lookup(counts, "a"), lookup(counts, "z"), lookup(undefined, "a")); +console.log(counts instanceof Map); + +// A nullable annotation takes the same arm. +const nullable: Map | null = new Map(); +console.log(nullable !== null, nullable instanceof Map); + +// A readonly view announces no writes; the value is still a real Map. +const readonlyView: ReadonlyMap | undefined = new Map(); +if (readonlyView !== undefined) { + console.log(readonlyView.size, readonlyView.get("missing") === undefined); +} + +// The contextual type of an optional record field is the same union. +type Registry = { + handlers?: Map; + names?: Set; +}; + +function createRegistry(): Registry { + return { handlers: new Map(), names: new Set() }; +} + +const registry = createRegistry(); +if (registry.handlers !== undefined) { + registry.handlers.set("on", 7); + console.log(registry.handlers.size, registry.handlers.get("on")); +} +if (registry.names !== undefined) { + registry.names.add("first"); + console.log(registry.names.size, registry.names.has("first")); +} + +// A parameter slot infers the same way for Map and Set. +function describe(map: Map | undefined, set: Set | undefined): string { + return `${map === undefined ? "-" : map.size}/${set === undefined ? "-" : set.size}`; +} +console.log(describe(new Map(), new Set()), describe(undefined, undefined)); + +// Iteration and JSON-free surfaces keep the element types. +const entries = new Map([["x", 1], ["y", 2]] as [string, number][]); +console.log([...entries.keys()].join(","), [...entries.values()].join(",")); diff --git a/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json b/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json new file mode 100644 index 000000000..6c25b05fd --- /dev/null +++ b/tests/harness/corpus-costs/8528-new-collection-contextual-union.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 3700 }