Read missing record keys as undefined before every absence guard

`let e = map[k]; if (e === undefined) { e = {}; map[k] = e } e[c] = v` on a `Record<string, Record<string, string>>` compiled, then aborted with "record has no key (typed slot…)" where Node prints the result. Without noUncheckedIndexedAccess a keyed record read keeps its typed trap unless the next statement tests the binding for absence, and only `if (!e)` counted. Comparisons with the missing value now count too (`=== undefined`, `== null`, `!== void 0`, `typeof e === "undefined"` and their mirrored forms) for index-signature record reads whose checker type lacks undefined. Module-scope declarations get the same treatment: their pre-registered global takes the undefined arm before any body reads it, and the initializer lowers as the absence probe. Array reads, annotated bindings and noUncheckedIndexedAccess programs compile as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 08:32:46 -07:00
co-authored by Claude Opus 5.5
parent dcdca2b810
commit ce87ba722d
4 changed files with 269 additions and 27 deletions
@@ -4334,16 +4334,21 @@ export function lowerVarDecl(
if (wrapped) return { kind: "assign", localId: g.id, value: wrapped, loc: locOf(decl) };
return null;
}
const raw = lowerVariableInitializer(
lowerer,
decl.initializer,
g.type.kind === "dyn" &&
(isNativeBuiltinValueInitializer(lowerer, decl.initializer) ||
ts.isArrayLiteralExpression(decl.initializer) ||
ts.isObjectLiteralExpression(decl.initializer))
? DYN
: undefined,
);
const guardedRead = isGuardedRecordRead(lowerer, decl)
? lowerAbsenceProbe(lowerer, decl.initializer)
: null;
const raw =
guardedRead ??
lowerVariableInitializer(
lowerer,
decl.initializer,
g.type.kind === "dyn" &&
(isNativeBuiltinValueInitializer(lowerer, decl.initializer) ||
ts.isArrayLiteralExpression(decl.initializer) ||
ts.isObjectLiteralExpression(decl.initializer))
? DYN
: undefined,
);
if (
isJsSourceFile(decl.getSourceFile()) &&
!decl.type &&
@@ -5139,29 +5144,111 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
}
function immediatelyGuardedAbsenceProbe(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return false;
const guard = immediateAbsenceGuard(lowerer, decl);
// `if (!x)`: the falsy test sees a missing value.
if (guard === "falsy") return true;
// `if (x === undefined)` and its spellings compare the binding with the
// missing value itself, so the read must be able to produce it. Only
// index-signature record reads take this form: array reads already
// carry their undefined arm, and their ordinary lowering keeps its
// bounds proofs.
return guard === "compare" && isIndexSignatureRecordRead(lowerer, decl);
}
/** A file-scope or block-scope `let e = map[k]` over an index-signature
* record whose next statement guards `e` for absence (`if (e ===
* undefined)`, `if (!e)`, ...): the get-or-create idiom. Its read keeps the
* missing key as undefined even without noUncheckedIndexedAccess. */
export function isGuardedRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
return immediateAbsenceGuard(lowerer, decl) !== null && isIndexSignatureRecordRead(lowerer, decl);
}
function isIndexSignatureRecordRead(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
if (!decl.initializer) return false;
let init = decl.initializer;
while (ts.isParenthesizedExpression(init)) init = init.expression;
if (!ts.isElementAccessExpression(init)) return false;
// Under noUncheckedIndexedAccess the checker already spells the missing
// arm, and the ordinary read carries it.
const read = lowerer.typeOf(init);
if (
(read.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0 ||
(read.isUnionType() &&
ts.constituentTypes(read).some((t) => (t.flags & ts.TypeFlags.Undefined) !== 0))
)
return false;
const receiver = lowerer.mapTypeOf(lowerer.typeOf(init.expression));
const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined;
return !!shape && !shape.tuple && !!shape.indexValue && shape.indexValue.kind !== "dyn";
}
/** How the statement right after an unannotated declaration tests the
* declared binding for absence: `if (!x)` ("falsy"), or a comparison with
* the missing value — `x === undefined`, `x == null`, `x !== void 0`,
* `typeof x === "undefined"` and their mirrored and negated spellings
* ("compare"). Null for anything else. */
function immediateAbsenceGuard(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
): "falsy" | "compare" | null {
if (decl.type || !ts.isIdentifier(decl.name) || !decl.initializer) return null;
const statement = decl.parent?.parent;
if (!statement || !ts.isVariableStatement(statement)) return false;
if (!statement || !ts.isVariableStatement(statement)) return null;
const container = statement.parent;
const statements =
ts.isBlock(container) || ts.isSourceFile(container) ? container.statements : null;
if (!statements) return false;
if (!statements) return null;
const index = statements.indexOf(statement);
const next = index >= 0 ? statements[index + 1] : undefined;
if (!next || !ts.isIfStatement(next)) return false;
let condition = next.expression;
while (ts.isParenthesizedExpression(condition)) condition = condition.expression;
if (
!ts.isPrefixUnaryExpression(condition) ||
condition.operator !== ts.SyntaxKind.ExclamationToken
)
return false;
let operand = condition.operand;
while (ts.isParenthesizedExpression(operand)) operand = operand.expression;
return (
ts.isIdentifier(operand) &&
lowerer.resolveValueSymbol(operand) === lowerer.resolveValueSymbol(decl.name)
);
if (!next || !ts.isIfStatement(next)) return null;
const binding = lowerer.resolveValueSymbol(decl.name);
const peel = (node: ts.Expression): ts.Expression => {
while (ts.isParenthesizedExpression(node)) node = node.expression;
return node;
};
const isBinding = (node: ts.Expression): boolean => {
const operand = peel(node);
return ts.isIdentifier(operand) && lowerer.resolveValueSymbol(operand) === binding;
};
const condition = peel(next.expression);
if (ts.isPrefixUnaryExpression(condition))
return condition.operator === ts.SyntaxKind.ExclamationToken && isBinding(condition.operand)
? "falsy"
: null;
if (!ts.isBinaryExpression(condition)) return null;
const op = condition.operatorToken.kind;
const strict =
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken;
const loose =
op === ts.SyntaxKind.EqualsEqualsToken || op === ts.SyntaxKind.ExclamationEqualsToken;
if (!strict && !loose) return null;
const isUndefined = (node: ts.Expression): boolean => {
const operand = peel(node);
if (ts.isVoidExpression(operand)) return true;
return (
ts.isIdentifier(operand) &&
operand.text === "undefined" &&
(lowerer.typeOf(operand).flags & ts.TypeFlags.Undefined) !== 0
);
};
const isNull = (node: ts.Expression): boolean => peel(node).kind === ts.SyntaxKind.NullKeyword;
const missing = (node: ts.Expression): boolean => isUndefined(node) || (loose && isNull(node));
const isTypeofBinding = (node: ts.Expression): boolean => {
const operand = peel(node);
return ts.isTypeOfExpression(operand) && isBinding(operand.expression);
};
const isUndefinedTag = (node: ts.Expression): boolean => {
const operand = peel(node);
return ts.isStringLiteralLike(operand) && operand.text === "undefined";
};
const { left, right } = condition;
return (isBinding(left) && missing(right)) ||
(missing(left) && isBinding(right)) ||
(isTypeofBinding(left) && isUndefinedTag(right)) ||
(isUndefinedTag(left) && isTypeofBinding(right))
? "compare"
: null;
}
function falsyExitRuntimeOptionalLocals(lowerer: Lowerer, stmt: ts.IfStatement): IrLocal[] {
@@ -525,6 +525,7 @@ import {
lowerForOf,
lowerForStatement,
matchGroupsPatternSource,
isGuardedRecordRead,
} from "./lower-stmts.js";
import {
type FieldTarget,
@@ -3908,6 +3909,33 @@ export class Lowerer {
}
}
/** A file-scope get-or-create read (`let e = map[k]; if (e ===
* undefined) ...` over an index-signature record) keeps a missing key as
* undefined (isGuardedRecordRead). Its pre-registered global takes the
* undefined arm before any body reads it; the declaration lowers its
* initializer as the matching absence probe. */
analyzeGuardedRecordReadGlobals(parts: FileParts[]): void {
for (const { sf } of parts) {
for (const statement of sf.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const decl of statement.declarationList.declarations) {
if (!ts.isIdentifier(decl.name) || !isGuardedRecordRead(this, decl)) continue;
const global = this.globalOf(decl.name);
const symbol = this.resolveValueSymbol(decl.name);
if (!global || !symbol || global.tdz || this.runtimeOptionalBindingTypes.has(symbol))
continue;
const widened =
global.type.kind === "union"
? this.withUndefinedArmOf(global.type)
: this.withUndefinedArm(global.type);
if (!widened || typeEquals(widened, global.type)) continue;
global.type = widened;
this.runtimeOptionalBindingTypes.set(symbol, widened);
}
}
}
}
analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void {
const provenance = RuntimeOptionalProvenance.fromEnvironment();
// The site whose value the current promotion follows (debug only).
@@ -6058,6 +6086,7 @@ export class Lowerer {
this.collectProgram(parts);
this.analyzeRuntimeOptionalArrayReads(parts);
this.analyzeDateJsonResults(parts);
this.analyzeGuardedRecordReadGlobals(parts);
// Decorated classes analyze AFTER the whole collection pass: a
// decorator's return type may name a subclass declared below the
// class, and reference lowering needs each class's rebindability
@@ -6443,6 +6472,7 @@ export class Lowerer {
this.collectProgram(parts);
this.analyzeRuntimeOptionalArrayReads(parts);
this.analyzeDateJsonResults(parts);
this.analyzeGuardedRecordReadGlobals(parts);
// Decorated classes analyze post-collection here too: the %init seeds
// lower the decoration calls, whose edges (decorator bodies, construct
// thunks) reachable emit must see.
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6100-record-get-or-create.ts"
],
"diags": []
}
+119
View File
@@ -0,0 +1,119 @@
// Get-or-create over nested index-signature records without
// noUncheckedIndexedAccess: a missing key reads as undefined when the next
// statement tests the binding for absence, in every spelling of that test,
// at module scope and inside functions. A write with a computed key through
// a binding that may hold a missing element throws Node's TypeError after
// the key and the value evaluated.
type ExportMap = Record<string, Record<string, string>>;
interface Target {
subpath: string;
condition: string;
target: string;
}
function build(targets: Target[]): { exports: ExportMap; unmapped: string[] } {
const exports: ExportMap = {};
const unmapped: string[] = [];
for (const t of targets) {
if (t.target === "") {
unmapped.push(t.subpath);
continue;
}
let entry = exports[t.subpath];
if (entry === undefined) {
entry = {};
exports[t.subpath] = entry;
}
entry[t.condition] = `./${t.target}`;
entry["types"] = `./${t.target}`;
}
return { exports: exports, unmapped: unmapped };
}
const result = build([
{ subpath: ".", condition: "import", target: "src/index.ts" },
{ subpath: ".", condition: "require", target: "src/index.ts" },
{ subpath: "./x", condition: "default", target: "" },
{ subpath: "./y", condition: "node", target: "src/y.ts" },
]);
console.log(JSON.stringify(result.exports), result.unmapped.join(","));
function group(words: string[]): Record<string, string[]> {
const byInitial: Record<string, string[]> = {};
for (const w of words) {
const k = w.slice(0, 1);
let list = byInitial[k];
if (list == null) {
list = [];
byInitial[k] = list;
}
list.push(w);
}
return byInitial;
}
console.log(JSON.stringify(group(["apple", "avocado", "banana", "blueberry", "cherry"])));
function count(words: string[]): Record<string, number> {
const counts: Record<string, number> = {};
for (const w of words) {
const n = counts[w];
if (typeof n === "undefined") counts[w] = 1;
else counts[w] = n + 1;
}
return counts;
}
console.log(JSON.stringify(count(["a", "b", "a", "c", "a"])));
function lookup(table: Record<string, string>, key: string): string {
const hit = table[key];
if (hit !== undefined) return hit;
return `<${key} missing>`;
}
console.log(lookup({ a: "A" }, "a"), lookup({ a: "A" }, "b"));
function rows(table: Record<string, Record<string, number>>, key: string, col: string): number {
const row = table[key];
if (row != undefined) return row[col] ?? -1;
return -2;
}
console.log(rows({ r: { c: 3 } }, "r", "c"), rows({ r: { c: 3 } }, "r", "d"), rows({}, "x", "c"));
// Module scope.
const nested: Record<string, Record<string, number>> = {};
const key = process.argv.length > 99 ? "unused" : "k";
let slot = nested[key];
if (slot === undefined) {
slot = {};
nested[key] = slot;
}
slot[key] = 1;
slot["fixed"] = 2;
console.log(JSON.stringify(nested));
const names: Record<string, string> = { present: "yes" };
const missing = names[key];
if (!missing) console.log("falsy guard:", missing);
const present = names["present"];
if (present === void 0) console.log("unreachable");
else console.log("present:", present);
// A write with a computed key through a binding that may hold a missing
// array element throws Node's TypeError naming the key, after evaluating
// the key and the value.
function probe(tables: Record<string, string>[]): void {
const firstTable = tables[0];
const lastTable = tables[5];
const pick = (k: string): string => {
console.log("key", k);
return k;
};
firstTable[pick("b")] = pick("second");
console.log(JSON.stringify(firstTable));
try {
lastTable[pick("c")] = pick("value");
} catch (e) {
console.log((e as Error).name, (e as Error).message);
}
}
probe([{ a: "first" }]);