From 61647a8e40d2455cd859146d88745b7cad8a3fce Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 21:20:57 -0500 Subject: [PATCH 1/2] Read typed-array elements through non-null assertions as undefined where Node does `t[i]!` on a typed array always took the numeric element read, which is NaN for an index outside the array. Node reads undefined there, so `t[n]! === t[n]!` was false instead of true, `${t[n]!}` was "NaN", and `typeof t[n]!` was "number". `t[i]!` now takes the numeric read only where its use converts the value to a number. Where the use tells undefined from NaN (an equality, a template span, `+` with a string, a comparison with a string, `!`, `typeof`, an array literal element, or an argument whose parameter admits undefined, such as `Object.is` and `String`), it reads undefined for an invalid index, like `t[i]`. Returns and plain bindings keep the numeric read. Corpus 7394. --- .../lowering/expressions/element-access.ts | 14 +++-- .../src/frontend/lowering/lower-exprs.ts | 25 +++++++- .../7394-non-null-typed-array-reads.ts.json | 6 ++ .../corpus/7394-non-null-typed-array-reads.ts | 59 +++++++++++++++++++ .../7394-non-null-typed-array-reads.ts.json | 1 + 5 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json create mode 100644 tests/corpus/7394-non-null-typed-array-reads.ts create mode 100644 tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 0f22a9622..9ac6a04b6 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -45,6 +45,7 @@ import { lowerDynMemberAssignment, lowerOptionalArrayIndexRead, lowerOptionalNumber, + assertedReadObservesUndefined, recordKeyResultOk, representedClassFieldTarget, runtimeOptionalElementKey, @@ -381,13 +382,18 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre if (index.type.kind !== "f64") { lowerer.unsupported("SC1090", expr.argumentExpression, "indexing with non-number keys"); } - // `t[i]!` asserts presence: the numeric element read (NaN when - // invalid). Every other read answers undefined for an invalid index, - // like Node. + // `t[i]!` performs no runtime check, but its static type is a number: + // it takes the numeric element read (NaN when invalid), except where + // the value reaches a use that tells undefined from NaN (an equality, a + // string conversion, `typeof`, an array element), which reads undefined + // for an invalid index like `t[i]`. Every other read answers undefined + // for an invalid index, like Node. // A read whose every consumer treats undefined like NaN (see // nan-coded-reads.ts) is the same plain numeric read. if ( - (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) || + (ts.isNonNullExpression(expr.parent) && + expr.parent.expression === expr && + !assertedReadObservesUndefined(lowerer, expr.parent)) || lowerer.nanCodedReads?.isNumericRead(expr) === true ) { return { diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index f2b601619..033f482d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1024,13 +1024,14 @@ function assertionOperand( ) ) return null; - // Array and typed-array element reads keep their own handling of a - // missing element. + // Array element reads keep their own handling of a missing element. A + // typed-array read is optional here only where its use tells undefined + // from NaN (assertedReadObservesUndefined). let operand: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(operand)) operand = operand.expression; if (ts.isElementAccessExpression(operand)) { const kind = lowerer.mapTypeOf(lowerer.typeOf(operand.expression))?.kind; - if (kind === "array" || kind === "bytes") return null; + if (kind === "array") return null; } const use = convertingUseOf(lowerer, expr); if (use === null) return null; @@ -1047,6 +1048,7 @@ function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; const parent = outer.parent; if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "value" : null; + if (ts.isTypeOfExpression(parent)) return "value"; if (ts.isPrefixUnaryExpression(parent)) { if (parent.operator === ts.SyntaxKind.ExclamationToken) return "value"; return parent.operator === ts.SyntaxKind.MinusToken || @@ -1143,6 +1145,23 @@ const NUMERIC_COMPOUNDS = new Set([ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ]); +/** Whether the use of `t[i]!` tells undefined from NaN: a converting use + * that takes the value itself (see convertingUseOf), or a destination that + * holds a missing element (see assertedElementKeepsUndefined) other than a + * return, whose slot has the function's number type. */ +export function assertedReadObservesUndefined( + lowerer: Lowerer, + node: ts.NonNullExpression, +): boolean { + if (convertingUseOf(lowerer, node) === "value") return true; + let outer: ts.Expression = node; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + if (ts.isReturnStatement(parent) || (ts.isArrowFunction(parent) && parent.body === outer)) + return false; + return assertedElementKeepsUndefined(lowerer, node); +} + export function runtimeOptionalAssertionErases( lowerer: Lowerer, expr: ts.Expression, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json new file mode 100644 index 000000000..5fbae0c10 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7394-non-null-typed-array-reads.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7394-non-null-typed-array-reads.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7394-non-null-typed-array-reads.ts b/tests/corpus/7394-non-null-typed-array-reads.ts new file mode 100644 index 000000000..6f1ec9808 --- /dev/null +++ b/tests/corpus/7394-non-null-typed-array-reads.ts @@ -0,0 +1,59 @@ +// Non-null assertions on typed-array element reads. `!` performs no +// runtime check: an index outside the array reads undefined, as in Node, +// wherever the use tells undefined from NaN (equality, string conversion, +// typeof, Object.is, an array element), and NaN where it converts the value +// to a number. + +const int32 = new Int32Array([1, -2, 3]); +const float64 = new Float64Array([1.5, NaN, -0]); +const uint8 = new Uint8Array([7, 255]); + +function show(v: unknown): string { + return Object.is(v, -0) ? "-0" : String(v); +} + +function line(label: string, parts: unknown[]): void { + console.log(label.padEnd(12), parts.map(show).join(" ")); +} + +for (const k of [0, 1, 2, 3, -1, 1.5]) { + line(`int32 ${k}`, [ + int32[k]! === int32[k]!, + int32[k]! !== int32[0]!, + int32[k]! === undefined, + int32[k]! == null, + Object.is(int32[k]!, undefined), + typeof int32[k]!, + `${int32[k]!}`, + "v=" + int32[k]!, + String(int32[k]!), + int32[k]! + 1, + int32[k]! < 2, + [int32[k]!].length, + ]); + line(`float64 ${k}`, [ + float64[k]! === float64[k]!, + float64[k]! === undefined, + Object.is(float64[k]!, NaN), + typeof float64[k]!, + `${float64[k]!}`, + float64[k]! * 2, + -float64[k]!, + ]); + line(`uint8 ${k}`, [ + uint8[k]! === uint8[k]!, + uint8[k]! == undefined, + typeof uint8[k]!, + `${uint8[k]!}`, + uint8[k]! | 0, + uint8[k]! > 100, + ]); +} + +const optional: number | undefined = uint8[5]!; +const listed = [int32[9]!, float64[0]!]; +line("slots", [optional, listed[0], listed[1], listed.length]); + +let total = 0; +for (let i = 0; i <= int32.length; i++) total += int32[i]!; +line("sum", [total]); diff --git a/tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json b/tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json new file mode 100644 index 000000000..ad10beaaa --- /dev/null +++ b/tests/harness/corpus-costs/7394-non-null-typed-array-reads.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 3900 } From 6722a01898b39cf0b472caa7d6994f6dd3003633 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 23 Oct 2026 23:28:09 -0500 Subject: [PATCH 2/2] Track asserted typed-array reads that can be undefined in element state --- .../compiler/src/frontend/lowering/lowerer.ts | 18 +++++++++++++++- .../corpus/7394-non-null-typed-array-reads.ts | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 2fcfc063b..af89c61a4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6488,6 +6488,22 @@ export class Lowerer { }); return keys; }; + // `t[i]!` on a typed array, stored into an array, keeps an invalid + // index's undefined (assertedReadObservesUndefined): `!` performs no + // runtime check. A read whose consumers treat undefined like NaN stores + // NaN instead. + const assertedReadMayBeUndefined = (node: ts.Expression): boolean => { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isNonNullExpression(e)) return false; + let read = e.expression; + while (ts.isParenthesizedExpression(read)) read = read.expression; + return ( + ts.isElementAccessExpression(read) && + this.mapTypeOf(this.typeOf(read.expression))?.kind === "bytes" && + !nanCoded.isNumericRead(read) + ); + }; const elementStates = new ArrayElementStates({ elementAdmitsUndefined: (key) => { siteDependencies?.read(key); @@ -6497,7 +6513,7 @@ export class Lowerer { allocationOwner: (node) => ownership.allocationOwner(node), arrayKeys: arrayKeysOf, mayBeUndefined: (node) => { - if (explicitlyNonNull(node)) return false; + if (explicitlyNonNull(node)) return assertedReadMayBeUndefined(node); if (mayBeOptional(node)) return true; // `undefined as unknown as T` keeps the undefined value. for ( diff --git a/tests/corpus/7394-non-null-typed-array-reads.ts b/tests/corpus/7394-non-null-typed-array-reads.ts index 6f1ec9808..bab1bcf9c 100644 --- a/tests/corpus/7394-non-null-typed-array-reads.ts +++ b/tests/corpus/7394-non-null-typed-array-reads.ts @@ -57,3 +57,24 @@ line("slots", [optional, listed[0], listed[1], listed.length]); let total = 0; for (let i = 0; i <= int32.length; i++) total += int32[i]!; line("sum", [total]); + +// Arrays that store an asserted read past the end hold undefined, which +// their callbacks see. +const pushed: number[] = []; +pushed.push(int32[7]!); +pushed.push(uint8[1]!); +const literal = [float64[5]!, float64[0]!]; +const assigned: number[] = [1, 2]; +assigned[0] = uint8[9]!; +let visited = 0; +pushed.forEach((v) => { + visited += v; +}); +line("callbacks", [ + pushed.map((v) => v * 2).join(","), + visited, + literal.map((v) => typeof v).join(","), + literal.filter((v) => v === undefined).length, + assigned.map((v) => v + 1).join(","), + assigned.some((v) => v === undefined), +]);