Answer undefined for missing Record and index-signature keys (#1236)

This commit is contained in:
Chris Tate
2026-10-18 10:58:02 -05:00
committed by GitHub
14 changed files with 577 additions and 50 deletions
@@ -2,6 +2,8 @@ Typed-array reads at an index that is not an integer in the array's range return
Ordinary arrays track holes separately from present `undefined` values. Missing reads and empty `pop()`/`shift()` return `undefined`, indexed writes can grow the array, and length growth creates holes. Ordinary arrays track holes separately from present `undefined` values. Missing reads and empty `pop()`/`shift()` return `undefined`, indexed writes can grow the array, and length growth creates holes.
Keyed reads of a `Record` or an index signature return `undefined` for a missing key, as in Node.js, with or without `noUncheckedIndexedAccess`. A missing array element or key that reaches storage that cannot hold `undefined`, such as a class field typed `number`, throws a `TypeError`.
Runtime traps cannot be caught. User-thrown values and supported exceptions, including JSON parse, checked-cast, filesystem, regex, and `String.prototype.repeat` count errors, can be caught; hard traps such as out-of-range reads of statically typed ordinary arrays terminate the process. Runtime traps cannot be caught. User-thrown values and supported exceptions, including JSON parse, checked-cast, filesystem, regex, and `String.prototype.repeat` count errors, can be caught; hard traps such as out-of-range reads of statically typed ordinary arrays terminate the process.
Deep recursion in Linux and macOS executables throws Node's catchable `RangeError: Maximum call stack size exceeded`, on the main thread, in worker threads, and inside async functions and generators. The depth at which it is thrown differs from Node: the main thread uses the process stack limit (usually 8 MiB), worker threads get an 8 MiB stack, and async functions and generators run on 512 KiB stacks, so native code usually recurses deeper than Node on threads and somewhat less deeply inside async bodies. Recursion inside runtime library operations, such as serializing deeply nested data, is not checked. Windows executables, WASI builds and library builds do not check the stack, and exhausting it there terminates the process. Deep recursion in Linux and macOS executables throws Node's catchable `RangeError: Maximum call stack size exceeded`, on the main thread, in worker threads, and inside async functions and generators. The depth at which it is thrown differs from Node: the main thread uses the process stack limit (usually 8 MiB), worker threads get an 8 MiB stack, and async functions and generators run on 512 KiB stacks, so native code usually recurses deeper than Node on threads and somewhat less deeply inside async bodies. Recursion inside runtime library operations, such as serializing deeply nested data, is not checked. Windows executables, WASI builds and library builds do not check the stack, and exhausting it there terminates the process.
@@ -11111,6 +11111,7 @@ export class LlEmitter {
resultType: IrType, resultType: IrType,
overflowOnly: boolean, overflowOnly: boolean,
loc?: SrcLoc, loc?: SrcLoc,
missNaN = false,
): void { ): void {
return keyedRecordReadInto( return keyedRecordReadInto(
this, this,
@@ -11122,6 +11123,7 @@ export class LlEmitter {
resultType, resultType,
overflowOnly, overflowOnly,
loc, loc,
missNaN,
); );
} }
@@ -339,6 +339,7 @@ export function emitRecordKeyGet(
e.type, e.type,
e.overflowOnly === true, e.overflowOnly === true,
e.loc, e.loc,
e.missNaN === true,
); );
B.startBlock(join); B.startBlock(join);
const t = B.tmp(); const t = B.tmp();
@@ -356,6 +357,7 @@ export function keyedRecordReadInto(
resultType: IrType, resultType: IrType,
overflowOnly: boolean, overflowOnly: boolean,
loc?: SrcLoc, loc?: SrcLoc,
missNaN = false,
): void { ): void {
const B = host.B; const B = host.B;
const shape = host.recordShape(shapeId); const shape = host.recordShape(shapeId);
@@ -563,6 +565,12 @@ export function keyedRecordReadInto(
B.br(join); B.br(join);
return; return;
} }
if (missNaN && resultType.kind === "f64") {
// A NaN-coded read: every consumer treats undefined like NaN.
B.line(`store double ${f64Lit(NaN)}, ptr ${slot}`);
B.br(join);
return;
}
// A direct result cannot represent a miss. This is unreachable for // A direct result cannot represent a miss. This is unreachable for
// programs whose behavior matches Node, but preserve the missing-key trap. // programs whose behavior matches Node, but preserve the missing-key trap.
host.needsBadKey = true; host.needsBadKey = true;
@@ -131,6 +131,7 @@ import { recordMayKeepSource, throughRecordSource } from "./record-sources.js";
import { lowerReflectCall } from "./builtins/reflection.js"; import { lowerReflectCall } from "./builtins/reflection.js";
import { import {
lowerAbsenceProbe, lowerAbsenceProbe,
v8CalleeText,
lowerOptionalNumber, lowerOptionalNumber,
lowerPromiseAllTupleCall, lowerPromiseAllTupleCall,
lowerPromiseRejectCall, lowerPromiseRejectCall,
@@ -1449,7 +1450,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
kind: "dynCall", kind: "dynCall",
callee: staticField, callee: staticField,
receiver: lowerer.lowerExprExpecting(expr.expression.expression, DYN), receiver: lowerer.lowerExprExpecting(expr.expression.expression, DYN),
calleeName: expr.expression.getText(), calleeName: v8CalleeText(expr.expression),
args: expr.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), args: expr.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN, type: DYN,
loc: locOf(expr), loc: locOf(expr),
@@ -1468,7 +1469,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
return { return {
kind: "dynCall", kind: "dynCall",
callee, callee,
calleeName: expr.expression.getText(), calleeName: v8CalleeText(expr.expression),
args, args,
type: DYN, type: DYN,
loc: locOf(expr), loc: locOf(expr),
@@ -1721,7 +1722,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
return { return {
kind: "dynCall", kind: "dynCall",
callee: lowerer.coerceToExpected(callee, DYN), callee: lowerer.coerceToExpected(callee, DYN),
calleeName: expr.expression.getText(), calleeName: v8CalleeText(expr.expression),
args: expr.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), args: expr.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN, type: DYN,
loc, loc,
@@ -1803,7 +1804,7 @@ function lowerCheckedCallableValue(
return { return {
kind: "dynCall", kind: "dynCall",
callee, callee,
calleeName: call.expression.getText(), calleeName: v8CalleeText(call.expression),
args: call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), args: call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN, type: DYN,
loc, loc,
@@ -3662,6 +3662,22 @@ function checkedStaleArm(
return helper ? lowerer.checkedNarrowCall(helper, value, arm, value.loc) : null; return helper ? lowerer.checkedNarrowCall(helper, value, arm, value.loc) : null;
} }
/** V8's spelling of a callee in "is not a function": a string-literal
* element key reads as a property name (`o["a"]` is `o.a`), other keys
* keep their brackets. */
export function v8CalleeText(node: ts.Node): string {
if (ts.isElementAccessExpression(node)) {
const key = node.argumentExpression;
const object = v8CalleeText(node.expression);
return ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)
? `${object}.${key.text}`
: `${object}[${key.getText()}]`;
}
if (ts.isPropertyAccessExpression(node) && !node.questionDotToken)
return `${v8CalleeText(node.expression)}.${node.name.text}`;
return node.getText();
}
/** Node's TypeError for the operation consuming `node` when it holds a /** Node's TypeError for the operation consuming `node` when it holds a
* value of type `found`, or null where Node would not throw. */ * value of type `found`, or null where Node would not throw. */
function staleReadMessage(node: ts.Node, found: IrType): string | null { function staleReadMessage(node: ts.Node, found: IrType): string | null {
@@ -3672,7 +3688,7 @@ function staleReadMessage(node: ts.Node, found: IrType): string | null {
parent = parent.parent; parent = parent.parent;
} }
if (ts.isCallExpression(parent) && parent.expression === child && !parent.questionDotToken) { if (ts.isCallExpression(parent) && parent.expression === child && !parent.questionDotToken) {
return found.kind === "func" ? null : `${node.getText()} is not a function`; return found.kind === "func" ? null : `${v8CalleeText(node)} is not a function`;
} }
const unit = found.kind === "undefinedT" ? "undefined" : found.kind === "nullT" ? "null" : null; const unit = found.kind === "undefinedT" ? "undefined" : found.kind === "nullT" ? "null" : null;
if (unit === null) return null; if (unit === null) return null;
@@ -7162,15 +7178,173 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
return lowerer.maybeNarrow({ kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, expr); return lowerer.maybeNarrow({ kind: "arrayGet", arr, index, type: elemT, loc: locOf(expr) }, expr);
} }
/** A record receiver that an outer keyed read left optional
* (`nested[a][b]`, where `nested[a]` can miss). The receiver evaluates
* once into `stmts`; `present` is its record arm, valid only once the
* access has tested `stable` against the undefined arm. */
interface OptionalRecordReceiver {
stmts: IrStmt[];
stable: IrExpr;
present: IrExpr;
unionId: string;
undefTag: number;
}
function optionalRecordReceiver(
lowerer: Lowerer,
value: IrExpr,
loc: SrcLoc,
): OptionalRecordReceiver | null {
if (value.type.kind !== "union") return null;
const unionId = value.type.unionId;
const undefTag = lowerer.armTag(unionId, UNDEFINED_T);
if (undefTag < 0 || lowerer.unions.get(unionId)?.arms.length !== 2) return null;
const record = lowerer.stripUndefinedArm(value.type);
if (record.kind !== "record") return null;
const stmts: IrStmt[] = [];
const stable = stabilized(lowerer, value, "%recordReceiver", stmts);
const present: IrExpr = {
kind: "unionNarrow",
unionId,
tag: lowerer.armTag(unionId, record),
value: stable,
type: record,
loc,
};
return { stmts, stable, present, unionId, undefTag };
}
/** `value`, evaluated once at this point of `out`. */
function stabilized(lowerer: Lowerer, value: IrExpr, name: string, out: IrStmt[]): IrExpr {
if (isSafeToRepeat(value)) return value;
const tmp = lowerer.declareHiddenLocal(name, value.type);
out.push({ kind: "varDecl", localId: tmp.id, init: value, loc: value.loc });
return varRef(tmp.id, value.type, value.loc);
}
function undefinedReceiver(receiver: OptionalRecordReceiver, loc: SrcLoc): IrExpr {
return {
kind: "unionIsTag",
unionId: receiver.unionId,
tag: receiver.undefTag,
negated: false,
value: receiver.stable,
type: BOOL,
loc,
};
}
/** Node's TypeError for a keyed read or write on an undefined receiver. */
function undefinedReceiverThrow(
key: IrExpr,
action: "read" | "set",
type: IrType,
loc: SrcLoc,
): IrExpr {
const head = `Cannot ${action} properties of undefined (${action === "read" ? "reading" : "setting"} '`;
const message: IrExpr = {
kind: "strConcat",
left: {
kind: "strConcat",
left: { kind: "strLit", value: head, type: STRING, loc },
right: key,
type: STRING,
loc,
},
right: { kind: "strLit", value: "')", type: STRING, loc },
type: STRING,
loc,
};
return {
kind: "libCall",
fn: "error.nodeThrow",
args: [numLit(1, loc), { kind: "strLit", value: "", type: STRING, loc }, message],
type,
loc,
};
}
/** The receiver and key of a keyed access whose receiver can be undefined,
* evaluated in order into `stmts`, with the receiver's record arm and the
* key's property-name string. Null when the receiver is not optional. */
function guardedRecordAccess(
lowerer: Lowerer,
access: ts.ElementAccessExpression,
rawObj: IrExpr,
): { receiver: OptionalRecordReceiver; key: IrExpr; name: IrExpr } | null {
const receiver = optionalRecordReceiver(lowerer, rawObj, locOf(access.expression));
if (!receiver) return null;
const keyNode = access.argumentExpression;
const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode);
const key =
litKey !== null
? ({ kind: "strLit", value: litKey, type: STRING, loc: locOf(keyNode) } satisfies IrExpr)
: stabilized(lowerer, lowerer.lowerExpr(keyNode), "%recordKey", receiver.stmts);
// A symbol key names no string property; its message spelling is not
// modeled.
const name =
key.type.kind === "symbol"
? ({ kind: "strLit", value: "Symbol()", type: STRING, loc: locOf(keyNode) } satisfies IrExpr)
: lowerRecordPropertyKey(lowerer, key, keyNode);
return { receiver, key, name };
}
/** `nested[a][b] = v` where `nested[a]` can miss: the receiver, the key and
* the value evaluate in order, then an undefined receiver throws Node's
* TypeError and a present one takes the write. Only member and identifier
* receivers are probed, since a probe that finds no optional receiver
* lowers the receiver again. */
function lowerGuardedRecordWrite(
lowerer: Lowerer,
expr: ts.BinaryExpression,
target: ts.ElementAccessExpression,
): IrStmt | null {
let receiverNode: ts.Expression = target.expression;
while (ts.isParenthesizedExpression(receiverNode)) receiverNode = receiverNode.expression;
if (
lowerer.chainRecvByNode.has(target.expression) ||
!(
ts.isElementAccessExpression(receiverNode) ||
ts.isPropertyAccessExpression(receiverNode) ||
ts.isIdentifier(receiverNode)
)
)
return null;
const guarded = guardedRecordAccess(lowerer, target, lowerer.lowerExpr(target.expression));
if (!guarded) return null;
const { receiver, key, name } = guarded;
const loc = locOf(expr);
const value = stabilized(lowerer, lowerer.lowerExpr(expr.right), "%recordValue", receiver.stmts);
const write = lowerer.withExpressionOverride(target.expression, receiver.present, () =>
lowerer.withExpressionOverride(target.argumentExpression, key, () =>
lowerer.withExpressionOverride(expr.right, value, () => lowerElementWrite(lowerer, expr)),
),
);
return {
kind: "block",
body: [
...receiver.stmts,
{
kind: "if",
cond: undefinedReceiver(receiver, loc),
then: [{ kind: "exprStmt", expr: undefinedReceiverThrow(name, "set", VOID, loc), loc }],
else_: [write],
loc,
},
],
loc,
};
}
/** `r[k]` over a (non-tuple) record shape. A LITERAL key naming a declared /** `r[k]` over a (non-tuple) record shape. A LITERAL key naming a declared
* field is the bracket spelling of field access (the ONLY spelling tsc * field is the bracket spelling of field access (the ONLY spelling tsc
* permits for signature-declared fields under * permits for signature-declared fields under
* noPropertyAccessFromIndexSignature) — an ordinary recordGet. Everything * noPropertyAccessFromIndexSignature) — an ordinary recordGet. Everything
* else is a runtime-keyed read (recordKeyGet): declared fields answer * else is a runtime-keyed read (recordKeyGet): declared fields answer
* first via an emitted string-switch, index-signature shapes fall through * first via an emitted string-switch, index-signature shapes fall through
* to the overflow map. The result type is the CHECKER's for the access — * to the overflow map. The result type is the index signature's value type
* the index signature's value type (dyn for `unknown`), or its * armed with undefined (dyn for `unknown`): a missing key reads undefined,
* undefined-armed union under noUncheckedIndexedAccess. Every declared * as in JavaScript, whatever noUncheckedIndexedAccess says. Every declared
* field must be able to SURFACE as that type (equal, an arm of it, or — * field must be able to SURFACE as that type (equal, an arm of it, or —
* for dyn results — JSON-safe for the dyn conversion); shapes mixing in * for dyn results — JSON-safe for the dyn conversion); shapes mixing in
* fields outside that stay fenced. Declared-only shapes support reads * fields outside that stay fenced. Declared-only shapes support reads
@@ -7194,9 +7368,39 @@ export function lowerRecordKeyRead(
// a generic instance) is the same static read — identifier evaluation // a generic instance) is the same static read — identifier evaluation
// is pure, so skipping it matches JS exactly. // is pure, so skipping it matches JS exactly.
const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode); const litKey = recordKeyLiteralText(keyNode) ?? recordKeyTypeLiteralText(lowerer, keyNode);
// An outer keyed read can leave the receiver undefined: evaluate the
// receiver and the key, then throw Node's TypeError or read the present
// record.
// The receiver lowers FIRST (both branches below read it, and JS // The receiver lowers FIRST (both branches below read it, and JS
// evaluates the receiver before the key). // evaluates the receiver before the key).
const obj = lowerer.lowerExpr(expr.expression); const obj = lowerer.lowerExpr(expr.expression);
{
const guarded = guardedRecordAccess(lowerer, expr, obj);
if (guarded) {
const { receiver, key, name } = guarded;
const read = lowerer.withExpressionOverride(expr.expression, receiver.present, () =>
litKey !== null
? lowerRecordKeyRead(lowerer, expr, shapeId, shape, includeUndefined)
: lowerer.withExpressionOverride(keyNode, key, () =>
lowerRecordKeyRead(lowerer, expr, shapeId, shape, includeUndefined),
),
);
return {
kind: "seqExpr",
stmts: receiver.stmts,
result: {
kind: "ternary",
cond: undefinedReceiver(receiver, loc),
then: undefinedReceiverThrow(name, "read", read.type, loc),
else_: read,
type: read.type,
loc,
},
type: read.type,
loc,
};
}
}
// A record-mapped CHECKER type over a VALUE living in the checked-dynamic tree (a JS // A record-mapped CHECKER type over a VALUE living in the checked-dynamic tree (a JS
// file-scope object-literal global): the checked-dynamic keyed read — // file-scope object-literal global): the checked-dynamic keyed read —
// dynKeyGet against the runtime keys (a missing key answers the checked-dynamic tree // dynKeyGet against the runtime keys (a missing key answers the checked-dynamic tree
@@ -7310,21 +7514,29 @@ export function lowerRecordKeyRead(
loc, loc,
}; };
} }
// The DECLARED result type of the access — the index signature's value // The result type of the access — the index signature's value type
// type (armed with undefined under noUncheckedIndexedAccess), or the // armed with undefined, or the declared fields' one common type on
// declared fields' one common type on signature-free shapes (tsc's // signature-free shapes (tsc's keyof check proved membership; a
// keyof check proved membership; a smuggled miss traps). The checker // smuggled miss traps). On signature-free shapes the checker may have
// may have NARROWED this occurrence (assignment CFA on literal keys) — // NARROWED this occurrence (assignment CFA on literal keys) — maybeNarrow
// maybeNarrow bridges to the narrowed arm exactly like a field read. // bridges to the narrowed arm exactly like a field read.
let declared: IrType | null = null; let declared: IrType | null = null;
// An index-signature read is a JavaScript property read: a missing key
// answers undefined even without noUncheckedIndexedAccess. The read keeps
// that explicit optional union, as an ordinary array read does.
let optionalRead = false;
// A read whose every consumer treats undefined like NaN (see
// nan-coded-reads.ts) is a plain number that is NaN for a missing key.
const nanCoded =
!includeUndefined &&
shape.indexValue?.kind === "f64" &&
lowerer.nanCodedReads?.isNumericRead(expr) === true;
if (shape.indexValue) { if (shape.indexValue) {
declared = shape.indexValue; declared = shape.indexValue;
if ( if (declared.kind !== "dyn" && !nanCoded) {
declared.kind !== "dyn" &&
(includeUndefined || lowerer.program.getCompilerOptions().noUncheckedIndexedAccess)
) {
declared = lowerer.withUndefinedArmOf(declared); declared = lowerer.withUndefinedArmOf(declared);
if (!declared) lowerer.badType(expr, lowerer.typeOf(expr)); if (!declared) lowerer.badType(expr, lowerer.typeOf(expr));
optionalRead = true;
} }
} else if ( } else if (
shape.fields.length > 0 && shape.fields.length > 0 &&
@@ -7357,10 +7569,12 @@ export function lowerRecordKeyRead(
shapeId, shapeId,
key, key,
...(overflowOnly ? { overflowOnly: true as const } : {}), ...(overflowOnly ? { overflowOnly: true as const } : {}),
...(nanCoded ? { missNaN: true as const } : {}),
type: declared, type: declared,
loc, loc,
}; };
return includeUndefined ? read : lowerer.maybeNarrow(read, expr); if (nanCoded) return read;
return includeUndefined || optionalRead ? read : lowerer.maybeNarrow(read, expr);
} }
/** ToPropertyKey for represented record keys. Missing array-derived string /** ToPropertyKey for represented record keys. Missing array-derived string
@@ -7468,7 +7682,8 @@ export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrE
* side of `||`/`??`). JavaScript answers undefined for missing record keys * side of `||`/`??`). JavaScript answers undefined for missing record keys
* and array indices even when noUncheckedIndexedAccess is disabled. Keep * and array indices even when noUncheckedIndexedAccess is disabled. Keep
* that value in an explicit union until the surrounding guard/default has * that value in an explicit union until the surrounding guard/default has
* consumed it; ordinary unchecked reads retain the typed trap. Assertions * consumed it, including class-field and declared-only reads that otherwise
* keep their typed storage. Assertions
* around the read are erased here because this context handles the missing * around the read are erased here because this context handles the missing
* arm instead of consuming it as the asserted type. */ * arm instead of consuming it as the asserted type. */
export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr | null { export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr | null {
@@ -8266,6 +8481,8 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
// the field's type through the emitted helper (a mismatch throws the // the field's type through the emitted helper (a mismatch throws the
// catchable TypeError — SEMANTICS.md). // catchable TypeError — SEMANTICS.md).
if (shape && !shape.tuple) { if (shape && !shape.tuple) {
const guarded = lowerGuardedRecordWrite(lowerer, expr, target);
if (guarded) return guarded;
// Literal keys — string literals AND numeric literals in their // Literal keys — string literals AND numeric literals in their
// canonical string spelling (`b[1] = v` writes field/key "1", JS's // canonical string spelling (`b[1] = v` writes field/key "1", JS's
// own key derivation) — name declared fields directly. A key // own key derivation) — name declared fields directly. A key
@@ -14808,19 +15025,35 @@ export function fieldTarget(
// all-unknown-fields cast — `(err as { code?: unknown }).code`): // all-unknown-fields cast — `(err as { code?: unknown }).code`):
// decline, and the dyn keyed-read fallback answers. // decline, and the dyn keyed-read fallback answers.
if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) { if (obj.type.kind === "union" && lowerer.armTag(obj.type.unionId, UNDEFINED_T) >= 0) {
// A nested property read such as `rows[i].value` has the same // A nested property read such as `rows[i].value` or
// unchecked outer array-read issue as tuple indexing. Validate // `table[k].value`: the outer read can miss. Validate the record
// the record arm before fieldGet/recordGet so a missing outer // arm before fieldGet/recordGet so a missing outer value raises
// value raises a catchable TypeError rather than reaching the // Node's TypeError rather than reaching the backend with a union
// backend with a union receiver. // receiver.
const present = lowerer.stripUndefinedArm(obj.type); const present = lowerer.stripUndefinedArm(obj.type);
const parent = access.parent;
const written =
parent !== undefined &&
ts.isBinaryExpression(parent) &&
parent.left === access &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
const helper = const helper =
present.kind === "record" present.kind === "record"
? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(access.expression)) ? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(access.expression))
: null; : null;
obj = helper obj =
? lowerer.checkedNarrowCall(helper, obj, present, locOf(access.expression)) (present.kind === "record"
: lowerer.maybeNarrow(obj, access.expression); ? lowerer.runtimeOptionalPropertyReceiver(
access.expression,
obj,
present,
access.name.text,
written ? "set" : "read",
)
: null) ??
(helper
? lowerer.checkedNarrowCall(helper, obj, present, locOf(access.expression))
: lowerer.maybeNarrow(obj, access.expression));
} }
if (obj.type.kind !== "record") return null; if (obj.type.kind !== "record") return null;
const actualShape = lowerer.shapes.get(obj.type.shapeId); const actualShape = lowerer.shapes.get(obj.type.shapeId);
@@ -15138,8 +15371,8 @@ export function fieldGetExpr(
} }
// Overflow dot reads: exactly the bracket read with a literal key — // Overflow dot reads: exactly the bracket read with a literal key —
// recordKeyGet, overflowOnly (the name declares no field by // recordKeyGet, overflowOnly (the name declares no field by
// construction), typed as the index value armed with undefined under // construction), typed as the index value armed with undefined
// noUncheckedIndexedAccess (mirroring lowerRecordKeyRead). // (mirroring lowerRecordKeyRead).
if (target.container === "recordOvf") { if (target.container === "recordOvf") {
let obj = target.obj; let obj = target.obj;
if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) { if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) {
@@ -15165,7 +15398,7 @@ export function fieldGetExpr(
`reading '${target.field}' on a record without an index signature`, `reading '${target.field}' on a record without an index signature`,
); );
let t: IrType = shape.indexValue; let t: IrType = shape.indexValue;
if (lowerer.program.getCompilerOptions().noUncheckedIndexedAccess) { if (t.kind !== "dyn") {
const armed = lowerer.withUndefinedArmOf(t); const armed = lowerer.withUndefinedArmOf(t);
if (!armed) lowerer.badType(blame, lowerer.typeOf(blame)); if (!armed) lowerer.badType(blame, lowerer.typeOf(blame));
t = armed; t = armed;
@@ -2825,18 +2825,20 @@ export function lowerBindingPattern(
// An UNDECLARED key of an INDEX-SIGNATURE shape (`const { m, n, p } // An UNDECLARED key of an INDEX-SIGNATURE shape (`const { m, n, p }
// = ....groups` over `{ [key: string]: string }` — the groups // = ....groups` over `{ [key: string]: string }` — the groups
// record, `Record<string, T>` tables generally): the overflow-map // record, `Record<string, T>` tables generally): the overflow-map
// read, exactly the dot access's recordKeyGet — a missing key // read, exactly the dot access's recordKeyGet — a missing key binds
// traps when the value type carries no undefined arm (the checker // the undefined arm (where a default applies like any field's).
// claimed V), or binds the undefined arm (where a default applies
// like any field's).
const loc = locOf(el); const loc = locOf(el);
const readType =
shape.indexValue.kind === "dyn"
? shape.indexValue
: (lowerer.withUndefinedArmOf(shape.indexValue) ?? shape.indexValue);
let value: IrExpr = { let value: IrExpr = {
kind: "recordKeyGet", kind: "recordKeyGet",
obj: srcRef(), obj: srcRef(),
shapeId: srcType.shapeId, shapeId: srcType.shapeId,
key: { kind: "strLit", value: propName, type: STRING, loc }, key: { kind: "strLit", value: propName, type: STRING, loc },
overflowOnly: true, overflowOnly: true,
type: shape.indexValue, type: readType,
loc, loc,
}; };
if (el.initializer) value = applyBindingDefault(lowerer, el, value); if (el.initializer) value = applyBindingDefault(lowerer, el, value);
@@ -1972,6 +1972,26 @@ export class Lowerer {
return fallback ?? null; return fallback ?? null;
} }
/** The index value of a keyed read of a record's index signature that
* can answer undefined for a missing key while its checker type does not
* admit undefined, or null. A literal key naming a declared field is a
* field read. */
optionalIndexSignatureRead(e: ts.ElementAccessExpression): IrType | null {
const receiver = panicSafe(() => this.mapTypeOf(this.typeOf(e.expression)), null);
if (receiver?.kind !== "record") return null;
const shape = this.shapes.get(receiver.shapeId);
if (!shape || shape.tuple || !shape.indexValue || shape.indexValue.kind === "dyn") return null;
const key = e.argumentExpression;
if (
(ts.isStringLiteral(key) || ts.isNumericLiteral(key)) &&
shape.fields.some((field) => field.name === key.text)
)
return null;
const read = panicSafe(() => this.mapTypeOf(this.typeOf(e)), null);
if (read?.kind === "union" && this.armTag(read.unionId, UNDEFINED_T) >= 0) return null;
return shape.indexValue;
}
runtimeOptionalIdentifierValue( runtimeOptionalIdentifierValue(
node: ts.Expression, node: ts.Expression,
): { value: IrExpr; present: IrType; unionId: string } | null { ): { value: IrExpr; present: IrType; unionId: string } | null {
@@ -2055,6 +2075,7 @@ export class Lowerer {
value: IrExpr, value: IrExpr,
expected: IrType, expected: IrType,
member: string, member: string,
action: "read" | "set" = "read",
): IrExpr | null { ): IrExpr | null {
const source = this.runtimeOptionalSourceValue(node, value); const source = this.runtimeOptionalSourceValue(node, value);
if (source?.type.kind !== "union") return null; if (source?.type.kind !== "union") return null;
@@ -2097,7 +2118,7 @@ export class Lowerer {
then: nodeThrowExpr( then: nodeThrowExpr(
1, 1,
"", "",
`Cannot read properties of ${unit} (reading '${member}')`, `Cannot ${action} properties of ${unit} (${action === "read" ? "reading" : "setting"} '${member}')`,
expected, expected,
loc, loc,
), ),
@@ -4112,6 +4133,7 @@ export class Lowerer {
const t = mappedOrNull(receiver); const t = mappedOrNull(receiver);
return t?.kind === "bytes" ? t.elem : null; return t?.kind === "bytes" ? t.elem : null;
}, },
isNumberRecordRead: (read) => this.optionalIndexSignatureRead(read)?.kind === "f64",
isNumber: (node) => mappedOrNull(node)?.kind === "f64", isNumber: (node) => mappedOrNull(node)?.kind === "f64",
isString: (node) => mappedOrNull(node)?.kind === "string", isString: (node) => mappedOrNull(node)?.kind === "string",
hasLength: (node) => { hasLength: (node) => {
@@ -4247,8 +4269,26 @@ export class Lowerer {
// Typed-array reads answer undefined for an invalid index too, unless // Typed-array reads answer undefined for an invalid index too, unless
// every consumer treats undefined like NaN. // every consumer treats undefined like NaN.
if (kind === "bytes") return !nanCoded.isNumericRead(e); if (kind === "bytes") return !nanCoded.isNumericRead(e);
if (kind === "record")
return this.optionalIndexSignatureRead(e) !== null && !nanCoded.isNumericRead(e);
return kind === "array"; return kind === "array";
}; };
/** `r.name` where the name resolves to the receiver's index signature. */
const isRecordIndexPropertyRead = (e: ts.PropertyAccessExpression): boolean => {
// A name that resolves to a declared member is not an index read, and
// only a receiver with an index signature maps to an indexed record.
if (symbolOf(e.name)) return false;
const receiverType = this.typeOf(e.expression);
if (this.checker.getIndexInfosOfType(receiverType).length === 0) return false;
const receiver = this.mapTypeOf(receiverType);
if (receiver?.kind !== "record") return false;
const shape = this.shapes.get(receiver.shapeId);
if (!shape || shape.tuple || !shape.indexValue || shape.indexValue.kind === "dyn")
return false;
if (shape.fields.some((field) => field.name === e.name.text)) return false;
const read = this.mapTypeOf(this.typeOf(e));
return !(read?.kind === "union" && this.armTag(read.unionId, UNDEFINED_T) >= 0);
};
const isDynamicObjectEntryRead = (node: ts.Expression): boolean => { const isDynamicObjectEntryRead = (node: ts.Expression): boolean => {
const read = peel(node); const read = peel(node);
if (!ts.isElementAccessExpression(read) || !ts.isIdentifier(read.expression)) return false; if (!ts.isElementAccessExpression(read) || !ts.isIdentifier(read.expression)) return false;
@@ -4337,6 +4377,7 @@ export class Lowerer {
if (assertsDefined(node)) return false; if (assertsDefined(node)) return false;
const e = peel(node); const e = peel(node);
if (isIndexedRead(e)) return true; if (isIndexedRead(e)) return true;
if (ts.isPropertyAccessExpression(e) && isRecordIndexPropertyRead(e)) return true;
if (ts.isPropertyAccessExpression(e) && isOptionalProcessStreamProperty(this, e)) return true; if (ts.isPropertyAccessExpression(e) && isOptionalProcessStreamProperty(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isSettledIteratorValueRead(this, e)) return true; if (ts.isPropertyAccessExpression(e) && isSettledIteratorValueRead(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this)) if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this))
@@ -4561,6 +4602,7 @@ export class Lowerer {
return out; return out;
}; };
const scanPattern = (name: ts.BindingName, init: ts.Expression): boolean => { const scanPattern = (name: ts.BindingName, init: ts.Expression): boolean => {
if (ts.isObjectBindingPattern(name)) return scanRecordPattern(name, init);
if (!ts.isArrayBindingPattern(name)) return false; if (!ts.isArrayBindingPattern(name)) return false;
const sourceType = this.mapTypeOf(this.typeOf(init)); const sourceType = this.mapTypeOf(this.typeOf(init));
if (sourceType?.kind !== "array") return false; if (sourceType?.kind !== "array") return false;
@@ -4596,6 +4638,40 @@ export class Lowerer {
}); });
return changed; return changed;
}; };
/** `const { a, b } = table` over an index-signature record: a name that
* is not a declared field reads the index signature and can be
* missing. */
const scanRecordPattern = (name: ts.ObjectBindingPattern, init: ts.Expression): boolean => {
const sourceType = this.mapTypeOf(this.typeOf(init));
if (sourceType?.kind !== "record") return false;
const shape = this.shapes.get(sourceType.shapeId);
const indexValue = shape?.indexValue;
if (!shape || shape.tuple || !indexValue || indexValue.kind === "dyn") return false;
let changed = false;
for (const el of name.elements) {
if (el.dotDotDotToken || el.initializer || !ts.isIdentifier(el.name)) continue;
const key = el.propertyName ?? el.name;
if (!ts.isIdentifier(key) && !ts.isStringLiteral(key) && !ts.isNumericLiteral(key))
continue;
if (shape.fields.some((field) => field.name === key.text)) continue;
const symbol = symbolOf(el.name);
if (!symbol || optionalSymbols.has(symbol)) continue;
const read = this.mapTypeOf(this.typeOf(el.name));
if (read?.kind === "union" && this.armTag(read.unionId, UNDEFINED_T) >= 0) continue;
const widened = this.withUndefinedArmOf(indexValue);
if (!widened) continue;
optionalSymbols.add(symbol);
provenance?.note("pattern-binding", el.name, init);
this.runtimeOptionalBindingTypes.set(symbol, widened);
const global = this.globalsBySymbol.get(symbol);
if (global) {
global.type = widened;
this.runtimeOptionalGlobals.add(global);
}
changed = true;
}
return changed;
};
const hofCallbackIndices = (name: string, hasInitialValue: boolean): number[] | null => { const hofCallbackIndices = (name: string, hasInitialValue: boolean): number[] | null => {
if (name === "reduce" || name === "reduceRight") return hasInitialValue ? [1] : [0, 1]; if (name === "reduce" || name === "reduceRight") return hasInitialValue ? [1] : [0, 1];
if ( if (
@@ -43,6 +43,9 @@ export interface NanCodedHost {
declarationOf(symbol: ts.Symbol): ts.Node | undefined; declarationOf(symbol: ts.Symbol): ts.Node | undefined;
/** The element kind of a typed-array receiver, or null. */ /** The element kind of a typed-array receiver, or null. */
bytesElemOf(receiver: ts.Expression): IrBytesElem | null; bytesElemOf(receiver: ts.Expression): IrBytesElem | null;
/** The read is a keyed read of a record's `number` index signature,
* which answers undefined for a missing key. */
isNumberRecordRead(read: ts.ElementAccessExpression): boolean;
/** The static IR type of the node is a plain number. */ /** The static IR type of the node is a plain number. */
isNumber(node: ts.Node): boolean; isNumber(node: ts.Node): boolean;
isString(node: ts.Expression): boolean; isString(node: ts.Expression): boolean;
@@ -315,7 +318,10 @@ export class NanCodedReads {
return; return;
} }
if (ts.isElementAccessExpression(node) && this.isReadCandidate(node)) { if (ts.isElementAccessExpression(node) && this.isReadCandidate(node)) {
const elem = this.host.bytesElemOf(node.expression); // A record's number values may hold a genuine NaN, like f64 elements.
const elem =
this.host.bytesElemOf(node.expression) ??
(this.host.isNumberRecordRead(node) ? "f64" : null);
if (elem) candidates.set(node, elem); if (elem) candidates.set(node, elem);
} }
}); });
@@ -393,7 +399,13 @@ export class NanCodedReads {
// native build of the compiler. // native build of the compiler.
const cached = this.symbolCache.get(node); const cached = this.symbolCache.get(node);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const symbol = this.host.symbolOf(node); let symbol = this.host.symbolOf(node);
// A method read through an instance (`h.get`) can resolve to another
// symbol than its declaration name; slots and call sites key on the
// declaration's own symbol.
const decl = symbol ? this.host.declarationOf(symbol) : undefined;
if (decl && ts.isMethodDeclaration(decl) && decl.name !== node && ts.isIdentifier(decl.name))
symbol = this.symbolOf(decl.name) ?? symbol;
this.symbolCache.set(node, symbol); this.symbolCache.set(node, symbol);
return symbol; return symbol;
} }
+15 -11
View File
@@ -3035,26 +3035,30 @@ export type IrExpr =
* Declared fields are tried FIRST (an emitted string-switch — field * Declared fields are tried FIRST (an emitted string-switch — field
* access exactness is preserved: a declared name always answers from the * access exactness is preserved: a declared name always answers from the
* struct slot), then the overflow map on index-signature shapes. `type` * struct slot), then the overflow map on index-signature shapes. `type`
* is the CHECKER's type for the access: the index signature's value type * is the index signature's value type armed with undefined (dyn for
* (dyn for `unknown`; with noUncheckedIndexedAccess, its * `unknown`), so a missing key reads undefined as in JavaScript, or the
* `V | undefined` union). A MISSING key produces: the undefined dyn * declared fields' common type on signature-free shapes. A MISSING key
* singleton when `type` is dyn; the undefined arm when `type` is an * produces: the undefined dyn singleton when `type` is dyn; the undefined
* undefined-armed union; otherwise a TRAP — the checker claimed V and no * arm when `type` is an undefined-armed union; NaN with `missNaN`;
* undefined is representable (the array OOB policy; on declared-only * otherwise a TRAP (declared-only shapes, where tsc's keyof check makes
* shapes tsc's keyof check makes the trap unreachable without an `as` * the trap unreachable without an `as` smuggle). Declared-field values
* smuggle). Declared-field values surface as `type`: V-typed fields read * surface as `type`: V-typed fields read directly, dyn results build a
* directly, dyn results build a dyn COPY of the field value (the dynFrom * dyn COPY of the field value (the dynFrom conversion — deep for
* conversion — deep for composites, documented), union results wrap. * composites, documented), union results wrap.
* The key and object are borrowed; refcounted results are owned (+1). * The key and object are borrowed; refcounted results are owned (+1).
* `overflowOnly` (set when the key is a LITERAL that names no declared * `overflowOnly` (set when the key is a LITERAL that names no declared
* field): the read touches only the overflow map — declared fields need * field): the read touches only the overflow map — declared fields need
* not surface as `type`, and the emitted helper skips the string-switch. */ * not surface as `type`, and the emitted helper skips the string-switch.
* `missNaN` (with an f64 `type`): a missing key produces NaN instead of
* trapping, for reads whose every consumer treats undefined like NaN
* (nan-coded-reads.ts). */
| { | {
kind: "recordKeyGet"; kind: "recordKeyGet";
obj: IrExpr; obj: IrExpr;
shapeId: string; shapeId: string;
key: IrExpr; key: IrExpr;
overflowOnly?: true; overflowOnly?: true;
missNaN?: true;
type: IrType; type: IrType;
loc: SrcLoc; loc: SrcLoc;
} }
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5952-record-missing-keys.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5953-nan-coded-method-returns.ts"
],
"diags": []
}
+127
View File
@@ -0,0 +1,127 @@
// A keyed read of a Record or index signature that misses answers
// undefined, as in Node, at module top level and inside functions, without
// noUncheckedIndexedAccess. Static types stay what TypeScript says.
export {};
const kp = String(Date.now());
const r: Record<string, number> = { [kp]: 1, a: 2 };
console.log(r["k"], r["a"], r[kp] === 1);
console.log("done");
function inside(): void {
const local: Record<string, number> = { [kp]: 1 };
console.log(local["k"], local[kp]);
}
inside();
// Bindings, reassignment, arithmetic, templates and conversions.
const v = r["missing"];
console.log(v, typeof v, v === undefined);
let w = r["nope"];
console.log(w);
w = 5;
console.log(w + 1);
console.log(r["x"] + 1, r["a"] + 1, `${r["x"]}`, String(r["x"]), r["x"] > 1, r["a"] > 1);
console.log(Math.max(r["a"], r["nope"]), isNaN(r["nope"]), r["nope"] == null);
// Parameters, returns, record fields and array elements.
function take(n: number): string {
return String(n);
}
console.log(take(r["q"]), take(r["a"]));
function give(k: string): number {
return r[k];
}
console.log(give("a"), give("zz"));
const o = { v: r["zz"] };
console.log(JSON.stringify(o), o.v);
const arr: number[] = [r["zz"], r["a"]];
console.log(arr.length, arr[0], arr[1]);
// Dot reads through the index signature and destructuring.
console.log(r.zz, r.a, r.zz === undefined);
const { a, missing } = r;
console.log(a, missing);
// Strings, booleans, arrays and unions as index values.
const s: Record<string, string> = { a: "x" };
console.log(s["b"], s["a"].toUpperCase(), s["b"]?.length);
try {
console.log(s["b"].length);
} catch (error) {
console.log((error as Error).message);
}
const b: Record<string, boolean> = { t: true };
console.log(b["f"], b["t"], !b["f"], b["f"] ? "y" : "n");
const nested: Record<string, number[]> = { a: [1] };
console.log(nested["b"], nested["a"].length, nested["b"]?.length);
const mixed: Record<string, number | string> = { n: 1, s: "x" };
const m2 = mixed["q"];
console.log(mixed["q"], typeof mixed["q"], mixed["n"], m2 === undefined);
// Interfaces with declared fields and an index signature.
interface Bag {
name: string;
[key: string]: string;
}
const bag: Bag = { name: "n" };
console.log(bag.name, bag["other"], bag.other);
// Methods returning a keyed read, and loops over present and missing keys.
class Holder {
table: Record<string, number> = { x: 1 };
get(k: string): number {
return this.table[k];
}
}
const h = new Holder();
console.log(h.get("x"), h.get("y"), h.table["z"]);
const counts: Record<string, number> = {};
for (const word of ["a", "b", "a"]) counts[word] = (counts[word] ?? 0) + 1;
console.log(JSON.stringify(counts), counts["c"]);
let total = 0;
for (const k of ["a", "zz"]) {
console.log(k, r[k], r[k] !== undefined);
total += r[k];
}
console.log(total);
if (r["zz"] === undefined) console.log("missing zz");
// Nested tables: a missing outer entry is undefined, and a member access
// on it throws Node's TypeError after the key evaluates.
const tables: Record<string, Record<string, number>> = { m: { one: 1 } };
console.log(tables["m"]["one"], tables.m.one, tables["m"]["two"]);
tables["m"]["three"] = 3;
tables.m.four = 4;
console.log(JSON.stringify(tables));
const objs: Record<string, { b: number }> = { a: { b: 1 } };
objs["a"].b = 5;
console.log(objs["a"].b, objs.a.b);
const dynamicKey = `z${kp.length > 0 ? "z" : ""}`;
for (const attempt of [
() => objs["zz"].b,
() => tables["zz"]["q"],
() => tables[dynamicKey]["q"],
() => {
tables["zz"]["q"] = 1;
},
() => {
objs[dynamicKey].b = 2;
},
]) {
try {
attempt();
} catch (error) {
console.log(error instanceof TypeError, (error as Error).message);
}
}
// Calling a function stored under a missing key throws Node's TypeError.
const handlers: Record<string, (n: number) => number> = { double: (n) => n * 2 };
const handlerName = "double";
console.log(handlers["double"](4), handlers[handlerName](5));
try {
console.log(handlers["triple"](4));
} catch (error) {
console.log(error instanceof TypeError, (error as Error).message);
}
@@ -0,0 +1,44 @@
// A method that returns a typed-array element or a numeric record entry
// answers undefined for a missing one when its caller can observe it, and
// a plain number when every caller only uses it numerically.
export {};
class Samples {
values = new Float64Array([1.5, 2.5]);
at(index: number): number {
return this.values[index];
}
sum(count: number): number {
let total = 0;
for (let i = 0; i < count; i++) total += this.at(i);
return total;
}
}
const samples = new Samples();
console.log(samples.at(0), samples.at(9));
const missing = samples.at(9);
console.log(missing === undefined, typeof missing);
console.log(samples.sum(2), samples.sum(3));
class Scores {
table: Record<string, number> = { ada: 3 };
score(name: string): number {
return this.table[name];
}
doubled(name: string): number {
return this.score(name) * 2;
}
}
const scores = new Scores();
console.log(scores.score("ada"), scores.score("bob"));
console.log(scores.doubled("ada"), scores.doubled("bob"));
class Readings {
data = new Float64Array(1);
read(index: number) {
return this.data[index];
}
}
const readings = new Readings();
const late = readings.read(5);
console.log(late, readings.read(0));
+4
View File
@@ -1911,6 +1911,8 @@
"5946-nested-record-field-optional-writes.ts": 6045, "5946-nested-record-field-optional-writes.ts": 6045,
"5950-tuple-predicate-methods.ts": 5990, "5950-tuple-predicate-methods.ts": 5990,
"5951-result-never-widening.ts": 4710, "5951-result-never-widening.ts": 4710,
"5952-record-missing-keys.ts": 6090,
"5953-nan-coded-method-returns.ts": 4100,
"5955-any-promise-methods.js": 3940, "5955-any-promise-methods.js": 3940,
"5956-erased-generic-members.ts": 6730, "5956-erased-generic-members.ts": 6730,
"5958-asserted-union-receiver.ts": 2430, "5958-asserted-union-receiver.ts": 2430,
@@ -4621,6 +4623,8 @@
"5946-nested-record-field-optional-writes.ts": 68117, "5946-nested-record-field-optional-writes.ts": 68117,
"5950-tuple-predicate-methods.ts": 7790, "5950-tuple-predicate-methods.ts": 7790,
"5951-result-never-widening.ts": 6120, "5951-result-never-widening.ts": 6120,
"5952-record-missing-keys.ts": 20400,
"5953-nan-coded-method-returns.ts": 15600,
"5955-any-promise-methods.js": 5120, "5955-any-promise-methods.js": 5120,
"5956-erased-generic-members.ts": 8750, "5956-erased-generic-members.ts": 8750,
"5958-asserted-union-receiver.ts": 3160, "5958-asserted-union-receiver.ts": 3160,