Read typed-array elements through non-null assertions as undefined where Node does (#2067)

This commit is contained in:
Chris Tate
2026-10-23 23:53:54 -05:00
committed by GitHub
6 changed files with 136 additions and 8 deletions
@@ -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 {
@@ -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>([
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,
@@ -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 (
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7394-non-null-typed-array-reads.ts"
],
"diags": []
}
@@ -0,0 +1,80 @@
// 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]);
// 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),
]);
@@ -0,0 +1 @@
{ "plain": 4300, "san": 3900 }