Keep a Map or Set handed to a record view as the collection

A Map or Set is a live collection: a record copy answers the members it copied
once and misses every later write, so `function freeId(seen: { has(k: string):
boolean })` called with a Map must answer through the Map.

The collection now carries identity into the record view, and the view is
registered as a collection view: it keeps checked identity without protocol
dispatch, because there is no stored member to read. Its method calls dispatch
on the collection at runtime (`scr_native_map_invoke`), and `size` reads the
collection's own member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 13:14:47 -07:00
co-authored by Claude Opus 5.5
parent 8e495686e1
commit b716775730
7 changed files with 168 additions and 3 deletions
@@ -190,6 +190,9 @@ function protocolSignature(
): ProtocolSignature | null {
// A guarded optional step's receiver still carries its nullish arms.
const recvTs = lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression));
// A record view of a Map or Set has no stored member to read: the call
// dispatches on the collection at runtime instead.
if (lowerer.classCollectionView(recvTs)) return null;
const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text);
if (!property) return null;
const declarations = property.declarations;
@@ -13,6 +13,11 @@ export class ClassProtocols {
/** The value types of the string-keyed dictionaries that keep checked
* identity (see markDictionaryViews). */
private readonly dictionaryViews = new Set<ts.Type>();
/** The structural types a lib Map or Set flows into while it answers one
* of their members (see markIdentityFlows). They keep checked identity
* without protocol dispatch: their members are invoked on the collection
* itself, not read from a property bag. */
private readonly collectionViews = new Set<ts.Symbol>();
constructor(
private readonly checker: ts.TypeChecker,
@@ -425,9 +430,16 @@ export class ClassProtocols {
const added = [...marked].filter((symbol) => !before.has(symbol));
// A copy of an instance drops the methods its class defines, so a view
// that exposes one of them stays live wherever such an instance may
// arrive.
// arrive. A record view of a collection needs no protocol: its checked
// identity is the collection itself, and its members are the
// collection's own methods, which protocol dispatch would read from a
// property bag instead of invoking.
const kept = new Set(
added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
added.filter(
(symbol) =>
!this.collectionViews.has(symbol) &&
(methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
),
);
let grew = true;
while (grew) {
@@ -635,6 +647,14 @@ export class ClassProtocols {
);
}
/** Whether one of `names` is a member the collection type itself
* declares (`has`, `get`, `size`, `keys`, ...). */
private exposesCollectionMember(collection: ts.Type, names: ReadonlySet<string>): boolean {
return this.checker
.getPropertiesOfType(collection)
.some((property) => names.has(ts.symbolName(property)));
}
/** Whether class `symbol` declares a get or set accessor for one of
* `names`. */
private hasAccessorFor(symbol: ts.Symbol, names: ReadonlySet<string>): boolean {
@@ -919,6 +939,41 @@ export class ClassProtocols {
.find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type;
}
/** A lib Map, Set, ReadonlyMap or ReadonlySet: a live collection. Handed
* to a structural type that spells one of its members, a copy would answer
* from its own fields and miss later writes to the collection, so the view
* stays the collection itself (checked-dynamic identity). */
private isBuiltinCollection(type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
const target = type.isTypeReference() ? type.getTarget() : type;
const symbol = target?.getSymbol() ?? type.getSymbol();
if (symbol === undefined) return false;
const name = ts.symbolName(symbol);
if (name !== "Map" && name !== "Set" && name !== "ReadonlyMap" && name !== "ReadonlySet")
return false;
return (
(symbol.flags & ts.SymbolFlags.Class) === 0 &&
symbol.declarations.length > 0 &&
symbol.declarations.every(
(declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile,
)
);
}
/** A builtin collection with an `any` or `unknown` type argument is
* UNTYPED (`new Map()`). It cannot back a structural view narrower than
* the values it may yield — `get` answers `any`, which the view declares
* as its own member type — so it keeps the record-shape refusal rather
* than carrying identity into the view. */
private isUntypedCollection(type: ts.Type): boolean {
if (!type.isTypeReference()) return true;
const args = this.checker.getTypeArguments(type);
return (
args.length === 0 ||
args.some((arg) => (arg.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0)
);
}
private isProgramClassInstance(type: ts.Type): boolean {
const symbol = type.getSymbol();
return (
@@ -941,6 +996,11 @@ export class ClassProtocols {
return element !== undefined && this.carriesIdentity(element, marked);
}
if (this.isProgramClassInstance(type)) return true;
// A Map or Set is a live collection: a record built from it answers the
// members it copies once and misses every later write. An untyped
// `new Map()` cannot back a narrower view (see isUntypedCollection), so
// only a fully typed collection carries identity into the view.
if (this.isBuiltinCollection(type)) return !this.isUntypedCollection(type);
// A live Pick or Omit view of a program class holds the instance itself.
if (this.pickOfProgramClass(type) && this.usesCheckedIdentity(type)) return true;
const symbol = type.getSymbol();
@@ -1013,6 +1073,17 @@ export class ClassProtocols {
if (from === symbol) return false;
const facts = this.facts;
if (facts !== null) {
// A Map or Set that reaches the view, which answers one of its
// members: a record copy has no live backing behind `has`, `get` or
// `size`, and Node's answer changes with every later write. The view
// keeps checked identity, but not protocol dispatch: its members are
// the collection's own methods.
if (this.isBuiltinCollection(source)) {
const names = new Set(
this.checker.getPropertiesOfType(target).map((property) => ts.symbolName(property)),
);
if (this.exposesCollectionMember(source, names)) this.collectionViews.add(symbol);
}
// The flow: the source's classes and marked type reach `symbol`.
const origins = facts.origins.get(symbol) ?? new Set<ts.Symbol>();
for (const origin of this.originsOf(source, facts)) origins.add(origin);
@@ -1029,6 +1100,18 @@ export class ClassProtocols {
return true;
}
/** Whether `type` is a structural view that a Map or Set reaches and that
* answers through the collection's own methods (see markIdentityFlows).
* Such a view keeps checked identity but never protocol dispatch: there
* is no stored member to read, and the collection answers the call. */
collectionView(type: ts.Type): boolean {
this.index();
if (type.isUnionType() || type.isIntersectionType())
return ts.constituentTypes(type).some((part) => this.collectionView(part));
const symbol = type.getSymbol();
return symbol !== undefined && this.collectionViews.has(symbol);
}
/** Class-backed protocols require live receiver dispatch. Other interfaces,
* including factories backed by static methods and ordinary records of
* closures, retain their fixed native layouts. */
@@ -1048,9 +1131,12 @@ export class ClassProtocols {
// it (a literal whose methods read `this`) or when it receives existing
// objects (markDictionaryViews).
const dictionaryValue = this.dictionaryValueType(type);
const view = type.getSymbol();
const answer =
this.protocol(type) ||
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue));
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)) ||
// A record view of a Map or Set answers through the collection itself.
(view !== undefined && this.collectionViews.has(view));
this.answers.set(type, answer);
return answer;
}
@@ -5119,6 +5119,13 @@ export class Lowerer {
return this.classProtocols.usesCheckedIdentity(type);
}
/** A record view a Map or Set answers: it keeps protocol identity but
* calls the collection's own methods, never a stored member. */
classCollectionView(type: ts.Type): boolean {
this.classProtocols ??= new ClassProtocols(this.checker, this.moduleOrder);
return this.classProtocols.collectionView(type);
}
/** Declared class fields may store a DYN value only when its static type
* is `unknown` or `object` (also with `null` or `undefined`, or an array
* of those), names checked protocol identity, is an owned runtime
@@ -131,3 +131,23 @@ console.log(view.tick());
`);
expect(kinds["view"]).toBe("dyn");
});
test("a Map received as a record with one of its members stays the collection", async () => {
const kinds = await globalKinds(`
type Exists = { has(key: string): boolean };
const byId = new Map<string, number>();
const view: Exists = byId;
console.log(view.has("a"), byId.has("a"));
`);
expect(kinds["view"]).toBe("dyn");
});
test("a Set received as a record with one of its members stays the collection", async () => {
const kinds = await globalKinds(`
type Exists = { has(key: string): boolean };
const seen = new Set<string>();
const view: Exists = seen;
console.log(view.has("a"), seen.has("a"));
`);
expect(kinds["view"]).toBe("dyn");
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6956-collection-record-views.ts"
],
"diags": []
}
@@ -0,0 +1,42 @@
// A Map or Set handed to a record type that spells one of its members stays
// the collection itself: later writes are visible through the view, and the
// view casts back to the same collection.
type Exists = { has(key: string): boolean };
function freeId(existing: Exists): string {
for (let i = 0; i < 5; i++) {
const id = "id" + i;
if (!existing.has(id)) return id;
}
return "none";
}
const byId = new Map<string, number>();
byId.set("id0", 1);
console.log(freeId(byId));
byId.set("id1", 2);
console.log(freeId(byId));
const seen = new Set<string>();
seen.add("id0");
seen.add("id1");
seen.add("id2");
console.log(freeId(seen));
// The view is live: entries added after the conversion are visible.
const view: Exists = byId;
byId.set("id3", 3);
console.log(view.has("id3"), byId.has("id3"));
console.log((view as unknown as Map<string, number>) === byId);
const setView: Exists = seen;
seen.add("id3");
console.log(setView.has("id3"), (setView as unknown as Set<string>) === seen);
// A record literal of the same shape keeps working.
console.log(freeId({ has: (key: string) => key === "id0" }));
// A member Map answers without a stored field.
type Sized = { readonly size: number };
const sized: Sized = byId;
byId.set("id4", 4);
console.log(sized.size, byId.size);
@@ -0,0 +1 @@
{ "plain": 2600, "san": 5500 }