Index strings whose binding still stores a union

`const w = words[i]; w[w.length - 1]` and the narrowed form
`typeof t === "string" ? t[t.length - 1] : ...` were refused with SC1090
("string indexing with this index/result shape"), in `if` statements as well
as in conditional expressions. A binding read from an array keeps an
undefined arm in its storage, and a typeof guard narrows only the checker's
type, so the receiver reached the string-index lowering as the storage
union rather than a string.

The refused path now captures the union and the index and reads the string
arm. A missing value performs the checked-dynamic keyed read on the unit,
which throws Node's "Cannot read properties of undefined (reading '<key>')"
with the evaluated key; a value arm the checker excluded fails the checked
extraction. Receivers that compiled before take the paths they took before.
This is path-to-regexp's tokensToRegexp shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 07:07:44 -07:00
co-authored by Claude Opus 5.5
parent 56269bdad8
commit 07b44cd4b1
4 changed files with 165 additions and 2 deletions
@@ -130,7 +130,7 @@ import {
lowerSafeBytesRead,
} from "./array-values.js";
import { strCharsCall } from "./containers/array-construction.js";
import { lowerOptionalStringIndex } from "./string-index.js";
import { lowerOptionalStringIndex, lowerUnionStringIndex } from "./string-index.js";
import { tryLowerIndexedComparison } from "./indexed-comparison.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
import { unsupportedModuleFeatureOf } from "../builtin-modules.js";
@@ -6879,6 +6879,19 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
locOf(expr),
);
}
// The checker narrowed the receiver to string, but its stored value
// is still the union (an array-derived binding, a typeof guard).
if (index.type.kind === "f64") {
const read = lowerUnionStringIndex(
lowerer,
recv,
index,
expr.argumentExpression,
lowerer.withUndefinedArm(STRING),
locOf(expr),
);
if (read) return read;
}
lowerer.unsupported(
"SC1090",
expr,
@@ -1,4 +1,15 @@
import { BOOL, F64, STRING, type IrExpr, type IrType, type SrcLoc } from "../../ir/ir.js";
import type * as ts from "../ts7/adapter.js";
import {
BOOL,
DYN,
F64,
STRING,
isUnitType,
type IrExpr,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import { numLit, varRef } from "../../ir/build.js";
import type { Lowerer } from "./lowerer.js";
@@ -66,3 +77,77 @@ export function lowerOptionalStringIndex(
loc,
};
}
/** A number-keyed read of a receiver the checker narrowed to string whose
* stored value is still a union: an array-derived binding keeps its
* undefined arm (`const t = tokens[i]`), and a `typeof t === "string"`
* guard narrows the checker's type without retagging the storage. Both
* operands are captured first, as for a plain string receiver. A unit arm
* performs the checked-dynamic keyed read on that unit, which throws Node's
* "Cannot read properties of undefined (reading '<key>')"; another value
* arm, which the checker has excluded, fails the checked extraction; the
* string arm reads like any string receiver. Null when the union has no
* string arm. */
export function lowerUnionStringIndex(
lowerer: Lowerer,
receiver: IrExpr,
index: IrExpr,
keyNode: ts.Node,
type: IrType,
loc: SrcLoc,
): IrExpr | null {
if (receiver.type.kind !== "union") return null;
const unionType = receiver.type;
const unionId = unionType.unionId;
const def = lowerer.unions.get(unionId);
const stringTag = lowerer.armTag(unionId, STRING);
if (!def || stringTag < 0) return null;
const receiverLocal = lowerer.declareHiddenLocal("%indexedUnion", unionType);
const indexLocal = lowerer.declareHiddenLocal("%unionStringIndex", F64);
const stored = (): IrExpr => varRef(receiverLocal.id, unionType, loc);
const key = (): IrExpr => varRef(indexLocal.id, F64, loc);
const stmts: IrStmt[] = [
{ kind: "varDecl", localId: receiverLocal.id, init: receiver, loc },
{ kind: "varDecl", localId: indexLocal.id, init: index, loc },
];
let text: IrExpr;
if (def.arms.every((arm, tag) => tag === stringTag || isUnitType(arm))) {
text = { kind: "unionNarrow", unionId, tag: stringTag, value: stored(), type: STRING, loc };
} else {
const helper = lowerer.narrowedArmHelper(unionId, STRING, loc);
if (helper === null) return null;
text = lowerer.checkedNarrowCall(helper, stored(), STRING, loc);
}
let result = lowerOptionalStringIndex(lowerer, text, key(), type, loc);
for (let tag = def.arms.length - 1; tag >= 0; tag--) {
const arm = def.arms[tag]!;
if (!isUnitType(arm)) continue;
const unit = arm.kind === "nullT" ? "null" : "undefined";
const thrown: IrExpr = {
kind: "dynCheck",
value: {
kind: "dynKeyGet",
value: {
kind: "dynFrom",
value: { kind: "unitLit", unit, type: arm, loc },
type: DYN,
loc,
},
key: lowerer.ensureString(key(), keyNode),
type: DYN,
loc,
},
type: STRING,
loc,
};
result = {
kind: "ternary",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: stored(), type: BOOL, loc },
then: lowerer.coerceToExpected(thrown, type),
else_: result,
type,
loc,
};
}
return { kind: "seqExpr", stmts, result, type, loc };
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6043-string-index-narrowed-union.ts"
],
"diags": []
}
@@ -0,0 +1,59 @@
// Indexing a string whose binding was read from an array or narrowed from a
// union with typeof. The receiver's stored value keeps its other arms, so an
// out-of-bounds array read throws Node's TypeError at the index, naming the
// key.
interface Key {
name: string;
}
type Token = string | Key;
// path-to-regexp's tokensToRegexp shape.
function endDelimited(tokens: Token[], delimiters: string): boolean {
const endToken = tokens[tokens.length - 1];
return typeof endToken === "string"
? delimiters.indexOf(endToken[endToken.length - 1]) > -1
: endToken === undefined;
}
console.log(
endDelimited(["a/", { name: "x" }], "/"),
endDelimited(["a/"], "/"),
endDelimited([], "/"),
endDelimited(["ab"], "/"),
);
function lastChar(tokens: Token[]): string {
const t = tokens[0];
if (typeof t === "string") return t[t.length - 1];
return "-";
}
console.log(lastChar(["xyz"]), lastChar([{ name: "k" }]));
function lastLetter(words: string[], i: number): string {
const w = words[i];
return w[w.length - 1];
}
console.log(lastLetter(["ab", "cd"], 1));
function at(words: string[], i: number, j: number): string | undefined {
const w = words[i];
return w[j];
}
console.log(at(["abc"], 0, 1), at(["abc"], 0, 7), at(["abc"], 0, 1.5), at(["abc"], 0, -1));
for (const j of [2, -0.5, 1e21]) {
try {
at(["abc"], 3, j);
} catch (e) {
console.log(e instanceof TypeError, (e as Error).message);
}
}
let calls = 0;
function key(): number {
calls++;
return 0;
}
function viaCall(words: string[]): string {
const w = words[0];
return w[key()];
}
console.log(viaCall(["q"]), calls);