Adopt a contextual union's map arm for a bare new Map()

`new Map()` has no type arguments, and the no-argument constructor gives
the checker nothing to infer them from: the expression types as
Map<unknown, unknown> however the destination spells it. Construction
adopted a contextual type only when it mapped to a map, so a nullable or
readonly annotation — `Map<K, V> | undefined`, `Map<K, V> | null`,
`ReadonlyMap<K, V> | 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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:10 -07:00
co-authored by Claude Opus 5.5
parent 223973a56b
commit 49b133751c
4 changed files with 84 additions and 3 deletions
@@ -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<K, V> | undefined`, or `ReadonlyMap<K, V> | 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") {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8528-new-collection-contextual-union.ts"
],
"diags": []
}
@@ -0,0 +1,59 @@
// `new Map()` (no type arguments) in a contextual slot whose type is a
// union: `Map<K, V> | undefined`, `ReadonlyMap<K, V> | null`. A Map value
// can only inhabit the map arm, so the construction adopts that layout
// instead of the checker's bare Map<unknown, unknown>.
function lookup(handlers: Map<string, number> | undefined, key: string): string {
if (handlers === undefined) return "absent";
const found = handlers.get(key);
return found === undefined ? "missing" : String(found);
}
const counts: Map<string, number> | 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<string, number> | 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<string, number> | 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<string, number>;
names?: Set<string>;
};
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<string, number> | undefined, set: Set<string> | 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(","));
@@ -0,0 +1 @@
{ "plain": 3100, "san": 3700 }