Read and write record fields through keys whose literal type names fields of one type

This commit is contained in:
Chris Tate
2026-10-29 16:20:02 -05:00
parent 5bddeb9e2e
commit 2cbdc9207f
5 changed files with 193 additions and 1 deletions
@@ -84,7 +84,13 @@ import { isSafeToDiscard, isSafeToRepeat } from "./evaluation-safety.js";
import { withInheritedMethods } from "../record-inherited.js";
import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js";
import { isClassCallback } from "../class-callbacks.js";
import { isKeyedFieldAccess } from "./keyed-field-targets.js";
import {
isKeyedFieldAccess,
keyedFieldRead,
keyedFieldWrite,
sameTypedFieldKeys,
type KeyedFieldTarget,
} from "./keyed-field-targets.js";
import { lowerFieldCompoundValue } from "./updates.js";
import { readsHeldEvolvingArray } from "../lower-stmts.js";
import {
@@ -1548,6 +1554,36 @@ export function lowerRecordKeyRead(
recordKeyResultOk(lowerer, shape, DYN)
)
declared = DYN;
// A key whose literal type names only declared fields of one type, on a
// record whose other fields have other types, reads the field it names.
// A called access (`r[key]()`) keeps the refusal.
const named =
!declared && litKey === null && !isCalledAccess(expr)
? sameTypedFieldKeys(lowerer, shape, keyNode)
: null;
if (named) {
const stmts: IrStmt[] = [];
const settle = (value: IrExpr, name: string, force = false): IrExpr => {
if (!force && isSafeToRepeat(value)) return value;
const temp = lowerer.declareHiddenLocal(name, value.type);
stmts.push({ kind: "varDecl", localId: temp.id, init: value, loc });
return varRef(temp.id, temp.type, loc);
};
const receiver = settle(obj, "%keyedReceiver", !isSafeToDiscard(key));
const target: KeyedFieldTarget = {
container: "recordKeyed",
obj: receiver,
shapeId,
field: named.keys[0]!,
keys: named.keys,
key: settle(key, "%keyedKey"),
fieldType: named.fieldType,
};
const get = keyedFieldRead(lowerer, target, loc, true);
const read: IrExpr =
stmts.length === 0 ? get : { kind: "seqExpr", stmts, result: get, type: get.type, loc };
return includeUndefined ? read : lowerer.maybeNarrow(read, expr);
}
if (!declared) {
lowerer.unsupported(
"SC1090",
@@ -2571,6 +2607,48 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
};
}
}
// A key whose literal type names only declared fields of one type
// writes the field it names. JavaScript evaluates the receiver, then
// the key, then the value; the dispatch reads the saved receiver and
// key once per named field.
const named =
litKey === null ? sameTypedFieldKeys(lowerer, shape, target.argumentExpression) : null;
if (named && !shape.fields.every((f) => typeEquals(f.type, named.fieldType))) {
const loc = locOf(expr);
const body: IrStmt[] = [];
const save = (value: IrExpr, name: string): IrExpr => {
if (value.kind === "varRef") return value;
const temp = lowerer.declareHiddenLocal(name, value.type);
body.push({ kind: "varDecl", localId: temp.id, init: value, loc });
return varRef(temp.id, temp.type, loc);
};
const obj = save(lowerer.lowerExpr(target.expression), "%keyedReceiver");
const rawKey = lowerRecordPropertyKey(
lowerer,
lowerer.lowerExpr(target.argumentExpression),
target.argumentExpression,
);
if (rawKey.type.kind !== "string") {
lowerer.unsupported(
"SC1090",
target.argumentExpression,
"indexing records with non-string or non-number keys",
);
}
const key = save(rawKey, "%keyedKey");
const value = lowerer.lowerExprExpecting(expr.right, named.fieldType);
const keyed: KeyedFieldTarget = {
container: "recordKeyed",
obj,
shapeId: receiverIr.shapeId,
field: named.keys[0]!,
keys: named.keys,
key,
fieldType: named.fieldType,
};
body.push(keyedFieldWrite(lowerer, keyed, value, loc));
return { kind: "block", body, loc };
}
if (!shape.indexValue) {
// A SIGNATURE-FREE shape whose declared fields share ONE type
// writes through the same per-shape dispatch (the mockable-clock
@@ -5,9 +5,12 @@ import {
DYN,
F64,
STRING,
shapeHasAccessorSlots,
typeEquals,
type IrExpr,
type IrRecordShape,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../../ir/ir.js";
import { isJsSourceFile } from "../../program.js";
@@ -141,6 +144,32 @@ export function keyedFieldTarget(
};
}
/** The declared fields a computed key names when the key's literal type
* allows only names of declared fields stored with one type
* (`stats[counter]` with `counter: "hits" | "misses"` on a record whose
* other fields have other types). Null for any other key or shape:
* accessor-carrying shapes, index-signature shapes and JavaScript sources
* keep their own keyed access. */
export function sameTypedFieldKeys(
lowerer: Lowerer,
shape: IrRecordShape,
keyNode: ts.Expression,
): { keys: string[]; fieldType: IrType } | null {
if (
shape.tuple ||
shape.indexValue !== undefined ||
shapeHasAccessorSlots(shape) ||
isJsSourceFile(ts.getSourceFileOfNode(keyNode))
)
return null;
const keys = literalFieldKeys(lowerer, keyNode);
if (!keys) return null;
const fields = keys.map((k) => shape.fields.find((f) => f.name === k));
const first = fields[0];
if (!first || fields.some((f) => !f || !typeEquals(f.type, first.type))) return null;
return { keys, fieldType: first.type };
}
/** Does `key` name `field`? */
function keyIs(key: IrExpr, field: string, loc: SrcLoc): IrExpr {
return {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/record-literal-key-unions.ts"
],
"diags": []
}
+78
View File
@@ -0,0 +1,78 @@
// Keyed reads and writes whose key is a union of literal names of declared
// fields stored with one type, on records whose other fields have other
// types: plain reads, plain writes, compound updates, keys from parameters
// and loops, and JavaScript evaluation order with effectful receivers and
// keys.
type Stats = { hits: number; misses: number; bytes: number; name: string; ok: boolean };
type Counter = "hits" | "misses" | "bytes";
type Flags = { name: string; verbose: boolean; strict: boolean; level: number };
type Labels = { id: number; title: string; summary: string; tags: string[] };
function read(s: Stats, key: Counter): number {
return s[key];
}
function bump(s: Stats, key: Counter, by: number): void {
s[key] = s[key] + by;
}
function toggle(flags: Flags, key: "verbose" | "strict"): boolean {
flags[key] = !flags[key];
return flags[key];
}
function relabel(labels: Labels, key: "title" | "summary", text: string): string {
const before = labels[key];
labels[key] = text.toUpperCase();
return `${before} -> ${labels[key]}`;
}
const stats: Stats[] = [];
for (let i = 0; i < 4; i++) stats.push({ hits: 0, misses: 0, bytes: 0, name: "s" + i, ok: true });
let seed = 7;
for (let i = 0; i < 1000; i++) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
const s = stats[seed & 3]!;
bump(s, seed % 5 === 0 ? "misses" : "hits", 1);
bump(s, "bytes", (seed >>> 8) & 255);
}
for (const s of stats) console.log(s.name, read(s, "hits"), read(s, "misses"), read(s, "bytes"), s.ok);
// Keys from a loop over a constant list of names.
const counters = ["hits", "misses", "bytes"] as const;
const totals: Stats = { hits: 0, misses: 0, bytes: 0, name: "total", ok: false };
for (const s of stats) for (const key of counters) totals[key] += s[key];
console.log(totals.name, totals.hits, totals.misses, totals.bytes);
// Booleans and strings.
const flags: Flags = { name: "build", verbose: false, strict: true, level: 2 };
console.log(toggle(flags, "verbose"), toggle(flags, "strict"), toggle(flags, "verbose"), flags.level);
const labels: Labels = { id: 1, title: "draft", summary: "none", tags: ["a"] };
console.log(relabel(labels, "title", "final"), relabel(labels, "summary", "short"), labels.tags.length);
// The receiver, then the key, then the value, each once.
const order: string[] = [];
function pick(i: number): Stats {
order.push("receiver " + i);
return stats[i]!;
}
function name(key: Counter): Counter {
order.push("key " + key);
return key;
}
function amount(n: number): number {
order.push("value " + n);
return n;
}
pick(1)[name("misses")] = amount(40);
console.log(order.join(", "), stats[1]!.misses);
order.length = 0;
console.log(pick(2)[name("bytes")], order.join(", "));
// A key that names one field.
function only(s: Stats, key: "bytes"): number {
s[key] = s[key] * 2;
return s[key];
}
console.log(only(stats[3]!, "bytes"));
@@ -0,0 +1 @@
{ "plain": 5800, "san": 62700 }