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.
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.
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,
overflowOnly: boolean,
loc?: SrcLoc,
missNaN = false,
): void {
return keyedRecordReadInto(
this,
@@ -11122,6 +11123,7 @@ export class LlEmitter {
resultType,
overflowOnly,
loc,
missNaN,
);
}
@@ -339,6 +339,7 @@ export function emitRecordKeyGet(
e.type,
e.overflowOnly === true,
e.loc,
e.missNaN === true,
);
B.startBlock(join);
const t = B.tmp();
@@ -356,6 +357,7 @@ export function keyedRecordReadInto(
resultType: IrType,
overflowOnly: boolean,
loc?: SrcLoc,
missNaN = false,
): void {
const B = host.B;
const shape = host.recordShape(shapeId);
@@ -563,6 +565,12 @@ export function keyedRecordReadInto(
B.br(join);
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
// programs whose behavior matches Node, but preserve the missing-key trap.
host.needsBadKey = true;
@@ -131,6 +131,7 @@ import { recordMayKeepSource, throughRecordSource } from "./record-sources.js";
import { lowerReflectCall } from "./builtins/reflection.js";
import {
lowerAbsenceProbe,
v8CalleeText,
lowerOptionalNumber,
lowerPromiseAllTupleCall,
lowerPromiseRejectCall,
@@ -1449,7 +1450,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
kind: "dynCall",
callee: staticField,
receiver: lowerer.lowerExprExpecting(expr.expression.expression, DYN),
calleeName: expr.expression.getText(),
calleeName: v8CalleeText(expr.expression),
args: expr.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN,
loc: locOf(expr),
@@ -1468,7 +1469,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
return {
kind: "dynCall",
callee,
calleeName: expr.expression.getText(),
calleeName: v8CalleeText(expr.expression),
args,
type: DYN,
loc: locOf(expr),
@@ -1721,7 +1722,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
return {
kind: "dynCall",
callee: lowerer.coerceToExpected(callee, DYN),
calleeName: expr.expression.getText(),
calleeName: v8CalleeText(expr.expression),
args: expr.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN,
loc,
@@ -1803,7 +1804,7 @@ function lowerCheckedCallableValue(
return {
kind: "dynCall",
callee,
calleeName: call.expression.getText(),
calleeName: v8CalleeText(call.expression),
args: call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN,
loc,
@@ -3662,6 +3662,22 @@ function checkedStaleArm(
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
* value of type `found`, or null where Node would not throw. */
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;
}
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;
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);
}
/** 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
* field is the bracket spelling of field access (the ONLY spelling tsc
* permits for signature-declared fields under
* noPropertyAccessFromIndexSignature) — an ordinary recordGet. Everything
* else is a runtime-keyed read (recordKeyGet): declared fields answer
* 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 —
* the index signature's value type (dyn for `unknown`), or its
* undefined-armed union under noUncheckedIndexedAccess. Every declared
* to the overflow map. The result type is the index signature's value type
* armed with undefined (dyn for `unknown`): a missing key reads undefined,
* as in JavaScript, whatever noUncheckedIndexedAccess says. Every declared
* 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
* 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
// is pure, so skipping it matches JS exactly.
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
// evaluates the receiver before the key).
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
// file-scope object-literal global): the checked-dynamic keyed read —
// dynKeyGet against the runtime keys (a missing key answers the checked-dynamic tree
@@ -7310,21 +7514,29 @@ export function lowerRecordKeyRead(
loc,
};
}
// The DECLARED result type of the access — the index signature's value
// type (armed with undefined under noUncheckedIndexedAccess), or the
// declared fields' one common type on signature-free shapes (tsc's
// keyof check proved membership; a smuggled miss traps). The checker
// may have NARROWED this occurrence (assignment CFA on literal keys) —
// maybeNarrow bridges to the narrowed arm exactly like a field read.
// The result type of the access — the index signature's value type
// armed with undefined, or the declared fields' one common type on
// signature-free shapes (tsc's keyof check proved membership; a
// smuggled miss traps). On signature-free shapes the checker may have
// NARROWED this occurrence (assignment CFA on literal keys) — maybeNarrow
// bridges to the narrowed arm exactly like a field read.
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) {
declared = shape.indexValue;
if (
declared.kind !== "dyn" &&
(includeUndefined || lowerer.program.getCompilerOptions().noUncheckedIndexedAccess)
) {
if (declared.kind !== "dyn" && !nanCoded) {
declared = lowerer.withUndefinedArmOf(declared);
if (!declared) lowerer.badType(expr, lowerer.typeOf(expr));
optionalRead = true;
}
} else if (
shape.fields.length > 0 &&
@@ -7357,10 +7569,12 @@ export function lowerRecordKeyRead(
shapeId,
key,
...(overflowOnly ? { overflowOnly: true as const } : {}),
...(nanCoded ? { missNaN: true as const } : {}),
type: declared,
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
@@ -7468,7 +7682,8 @@ export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrE
* side of `||`/`??`). JavaScript answers undefined for missing record keys
* and array indices even when noUncheckedIndexedAccess is disabled. Keep
* 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
* arm instead of consuming it as the asserted type. */
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
// catchable TypeError — SEMANTICS.md).
if (shape && !shape.tuple) {
const guarded = lowerGuardedRecordWrite(lowerer, expr, target);
if (guarded) return guarded;
// Literal keys — string literals AND numeric literals in their
// canonical string spelling (`b[1] = v` writes field/key "1", JS's
// 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`):
// decline, and the dyn keyed-read fallback answers.
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
// unchecked outer array-read issue as tuple indexing. Validate
// the record arm before fieldGet/recordGet so a missing outer
// value raises a catchable TypeError rather than reaching the
// backend with a union receiver.
// A nested property read such as `rows[i].value` or
// `table[k].value`: the outer read can miss. Validate the record
// arm before fieldGet/recordGet so a missing outer value raises
// Node's TypeError rather than reaching the backend with a union
// receiver.
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 =
present.kind === "record"
? lowerer.narrowedArmHelper(obj.type.unionId, present, locOf(access.expression))
: null;
obj = helper
? lowerer.checkedNarrowCall(helper, obj, present, locOf(access.expression))
: lowerer.maybeNarrow(obj, access.expression);
obj =
(present.kind === "record"
? 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;
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 —
// recordKeyGet, overflowOnly (the name declares no field by
// construction), typed as the index value armed with undefined under
// noUncheckedIndexedAccess (mirroring lowerRecordKeyRead).
// construction), typed as the index value armed with undefined
// (mirroring lowerRecordKeyRead).
if (target.container === "recordOvf") {
let obj = target.obj;
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`,
);
let t: IrType = shape.indexValue;
if (lowerer.program.getCompilerOptions().noUncheckedIndexedAccess) {
if (t.kind !== "dyn") {
const armed = lowerer.withUndefinedArmOf(t);
if (!armed) lowerer.badType(blame, lowerer.typeOf(blame));
t = armed;
@@ -2825,18 +2825,20 @@ export function lowerBindingPattern(
// An UNDECLARED key of an INDEX-SIGNATURE shape (`const { m, n, p }
// = ....groups` over `{ [key: string]: string }` — the groups
// record, `Record<string, T>` tables generally): the overflow-map
// read, exactly the dot access's recordKeyGet — a missing key
// traps when the value type carries no undefined arm (the checker
// claimed V), or binds the undefined arm (where a default applies
// like any field's).
// read, exactly the dot access's recordKeyGet — a missing key binds
// the undefined arm (where a default applies like any field's).
const loc = locOf(el);
const readType =
shape.indexValue.kind === "dyn"
? shape.indexValue
: (lowerer.withUndefinedArmOf(shape.indexValue) ?? shape.indexValue);
let value: IrExpr = {
kind: "recordKeyGet",
obj: srcRef(),
shapeId: srcType.shapeId,
key: { kind: "strLit", value: propName, type: STRING, loc },
overflowOnly: true,
type: shape.indexValue,
type: readType,
loc,
};
if (el.initializer) value = applyBindingDefault(lowerer, el, value);
@@ -1972,6 +1972,26 @@ export class Lowerer {
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(
node: ts.Expression,
): { value: IrExpr; present: IrType; unionId: string } | null {
@@ -2055,6 +2075,7 @@ export class Lowerer {
value: IrExpr,
expected: IrType,
member: string,
action: "read" | "set" = "read",
): IrExpr | null {
const source = this.runtimeOptionalSourceValue(node, value);
if (source?.type.kind !== "union") return null;
@@ -2097,7 +2118,7 @@ export class Lowerer {
then: nodeThrowExpr(
1,
"",
`Cannot read properties of ${unit} (reading '${member}')`,
`Cannot ${action} properties of ${unit} (${action === "read" ? "reading" : "setting"} '${member}')`,
expected,
loc,
),
@@ -4112,6 +4133,7 @@ export class Lowerer {
const t = mappedOrNull(receiver);
return t?.kind === "bytes" ? t.elem : null;
},
isNumberRecordRead: (read) => this.optionalIndexSignatureRead(read)?.kind === "f64",
isNumber: (node) => mappedOrNull(node)?.kind === "f64",
isString: (node) => mappedOrNull(node)?.kind === "string",
hasLength: (node) => {
@@ -4247,8 +4269,26 @@ export class Lowerer {
// Typed-array reads answer undefined for an invalid index too, unless
// every consumer treats undefined like NaN.
if (kind === "bytes") return !nanCoded.isNumericRead(e);
if (kind === "record")
return this.optionalIndexSignatureRead(e) !== null && !nanCoded.isNumericRead(e);
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 read = peel(node);
if (!ts.isElementAccessExpression(read) || !ts.isIdentifier(read.expression)) return false;
@@ -4337,6 +4377,7 @@ export class Lowerer {
if (assertsDefined(node)) return false;
const e = peel(node);
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) && isSettledIteratorValueRead(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this))
@@ -4561,6 +4602,7 @@ export class Lowerer {
return out;
};
const scanPattern = (name: ts.BindingName, init: ts.Expression): boolean => {
if (ts.isObjectBindingPattern(name)) return scanRecordPattern(name, init);
if (!ts.isArrayBindingPattern(name)) return false;
const sourceType = this.mapTypeOf(this.typeOf(init));
if (sourceType?.kind !== "array") return false;
@@ -4596,6 +4638,40 @@ export class Lowerer {
});
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 => {
if (name === "reduce" || name === "reduceRight") return hasInitialValue ? [1] : [0, 1];
if (
@@ -43,6 +43,9 @@ export interface NanCodedHost {
declarationOf(symbol: ts.Symbol): ts.Node | undefined;
/** The element kind of a typed-array receiver, or 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. */
isNumber(node: ts.Node): boolean;
isString(node: ts.Expression): boolean;
@@ -315,7 +318,10 @@ export class NanCodedReads {
return;
}
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);
}
});
@@ -393,7 +399,13 @@ export class NanCodedReads {
// native build of the compiler.
const cached = this.symbolCache.get(node);
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);
return symbol;
}
+15 -11
View File
@@ -3035,26 +3035,30 @@ export type IrExpr =
* Declared fields are tried FIRST (an emitted string-switch — field
* access exactness is preserved: a declared name always answers from the
* 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
* (dyn for `unknown`; with noUncheckedIndexedAccess, its
* `V | undefined` union). A MISSING key produces: the undefined dyn
* singleton when `type` is dyn; the undefined arm when `type` is an
* undefined-armed union; otherwise a TRAP — the checker claimed V and no
* undefined is representable (the array OOB policy; on declared-only
* shapes tsc's keyof check makes the trap unreachable without an `as`
* smuggle). Declared-field values surface as `type`: V-typed fields read
* directly, dyn results build a dyn COPY of the field value (the dynFrom
* conversion — deep for composites, documented), union results wrap.
* is the index signature's value type armed with undefined (dyn for
* `unknown`), so a missing key reads undefined as in JavaScript, or the
* declared fields' common type on signature-free shapes. A MISSING key
* produces: the undefined dyn singleton when `type` is dyn; the undefined
* arm when `type` is an undefined-armed union; NaN with `missNaN`;
* otherwise a TRAP (declared-only shapes, where tsc's keyof check makes
* the trap unreachable without an `as` smuggle). Declared-field values
* surface as `type`: V-typed fields read directly, dyn results build a
* dyn COPY of the field value (the dynFrom conversion — deep for
* composites, documented), union results wrap.
* The key and object are borrowed; refcounted results are owned (+1).
* `overflowOnly` (set when the key is a LITERAL that names no declared
* 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";
obj: IrExpr;
shapeId: string;
key: IrExpr;
overflowOnly?: true;
missNaN?: true;
type: IrType;
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,
"5950-tuple-predicate-methods.ts": 5990,
"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,
"5956-erased-generic-members.ts": 6730,
"5958-asserted-union-receiver.ts": 2430,
@@ -4621,6 +4623,8 @@
"5946-nested-record-field-optional-writes.ts": 68117,
"5950-tuple-predicate-methods.ts": 7790,
"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,
"5956-erased-generic-members.ts": 8750,
"5958-asserted-union-receiver.ts": 3160,