From e80b5f1447275f3b7825dd766d6dce3abeddc578 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 14:57:21 -0500 Subject: [PATCH 1/2] Compile `.length` of computed tuple expressions `.length` of a tuple is its arity, and the lowering folded it to that constant only when the receiver was rooted at a binding or `this`. Any other receiver (a call result, a ternary, an `as` assertion, an `as const` literal) was refused with SC1090. The receiver is now evaluated once, for its side effects and exceptions, and its value discarded; the expression is the arity. A binding, `this`, or a chain of plain property reads from one still folds. A chain through an accessor is evaluated, so an accessor that returns a tuple runs, as in Node. --- .../lowering/expressions/property-access.ts | 49 +++++++++---- .../corpus/tuple-length-computed.ts.json | 6 ++ tests/corpus/tuple-length-computed.ts | 73 +++++++++++++++++++ tests/diagnostics/tuples.ts | 6 -- tests/harness/__snapshots__/tuples.ts.txt | 23 ++---- .../tuple-length-computed.ts.json | 1 + 6 files changed, 124 insertions(+), 34 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json create mode 100644 tests/corpus/tuple-length-computed.ts create mode 100644 tests/harness/corpus-costs/tuple-length-computed.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index ddc5e881b..0287f0d64 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3221,6 +3221,22 @@ export function lowerGroupsProjection( /** Field reads use the shared FieldTarget union. Native method reads select * an interned unbound callable; invocation supplies its receiver separately. */ +/** Whether a receiver is a binding or `this`, or a chain of property reads + * from one whose every member is a plain property (no accessor), so that + * reading it has no effect. */ +function plainReadChain(lowerer: Lowerer, node: ts.Expression): boolean { + let current = node; + while (ts.isPropertyAccessExpression(current)) { + if (current.questionDotToken) return false; + const member = lowerer.checker.getSymbolAtLocation(current.name); + if (member === undefined || (member.flags & ts.SymbolFlags.Property) === 0) return false; + if ((member.flags & (ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) !== 0) + return false; + current = current.expression; + } + return ts.isIdentifier(current) || current.kind === ts.SyntaxKind.ThisKeyword; +} + export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { // A checked receiver already owns its property table. An ambient // generic method signature must not divert its value into native @@ -3375,22 +3391,29 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi } } // `t.length` on a tuple: the arity CONSTANT (tuples are fixed-shape — - // the checker types it as the literal arity too). Folding discards the - // receiver's evaluation, so only side-effect-free receivers fold; - // anything else (a call result) binds to a const first. + // the checker types it as the literal arity too). A binding or `this`, + // or a chain of plain property reads from one, folds; any other receiver + // (a call result, a ternary, an assertion, a chain through an accessor) + // is evaluated first, for its side effects and exceptions, and its value + // discarded. if (receiverIr?.kind === "record" && expr.name.text === "length") { const shape = lowerer.shapes.get(receiverIr.shapeId); if (shape?.tuple) { - let root: ts.Expression = expr.expression; - while (ts.isPropertyAccessExpression(root)) root = root.expression; - if (!ts.isIdentifier(root) && root.kind !== ts.SyntaxKind.ThisKeyword) { - lowerer.unsupported( - "SC1090", - expr, - "'.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first)", - ); - } - return { kind: "numLit", value: shape.fields.length, type: F64, loc: locOf(expr) }; + const arity: IrExpr = { + kind: "numLit", + value: shape.fields.length, + type: F64, + loc: locOf(expr), + }; + if (plainReadChain(lowerer, expr.expression)) return arity; + const receiver = checkerArray ?? lowerer.lowerExpr(expr.expression); + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: receiver, loc: locOf(expr.expression) }], + result: arity, + type: F64, + loc: locOf(expr), + }; } } return null; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json new file mode 100644 index 000000000..3662dd97d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/tuple-length-computed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/tuple-length-computed.ts" + ], + "diags": [] +} diff --git a/tests/corpus/tuple-length-computed.ts b/tests/corpus/tuple-length-computed.ts new file mode 100644 index 000000000..17a21dc76 --- /dev/null +++ b/tests/corpus/tuple-length-computed.ts @@ -0,0 +1,73 @@ +// `.length` of computed tuple expressions: call results, ternaries, `as` +// assertions, `as const` literals, member reads (fields and accessors) and +// element reads. The receiver is evaluated once, in order, for its side +// effects and exceptions, and the length is the tuple's arity. + +const log: string[] = []; + +function pair(tag: string): [number, string] { + log.push(`pair ${tag}`); + return [1, tag]; +} + +function triple(): [number, string, boolean] { + log.push("triple"); + return [1, "x", true]; +} + +function failing(): [number, number] { + log.push("failing"); + throw new Error("no tuple"); +} + +class Holder { + plain: [string, string] = ["a", "b"]; + reads = 0; + get viaAccessor(): [number, number, number] { + this.reads++; + log.push("accessor"); + return [this.reads, 2, 3]; + } + ownLength(): number { + return this.plain.length + this.viaAccessor.length; + } +} + +const nested: [[number, number], [string, string, string]] = [ + [1, 2], + ["a", "b", "c"], +]; + +let flip = 0; +function pick(): boolean { + flip++; + return flip % 2 === 1; +} + +console.log(pair("call").length); +console.log((pick() ? triple() : ([0, "", false] as [number, string, boolean])).length); +console.log((pick() ? triple() : ([0, "", false] as [number, string, boolean])).length); +console.log(([1, 2, 3] as const).length); +console.log(([flip, "y"] as [number, string]).length); + +const h = new Holder(); +console.log(h.plain.length, h.viaAccessor.length, h.viaAccessor.length, h.reads); +console.log(h.ownLength(), h.reads); +console.log(nested[0].length, nested[1].length, nested.length); + +// Evaluation order inside one expression, and in a loop condition. +const sum = pair("left").length + triple().length * 10 + pair("right").length * 100; +console.log(sum); +let rounds = 0; +for (let i = 0; i < pair(`loop ${i}`).length; i++) rounds++; +console.log(rounds); +console.log(`${pair("template").length}:${typeof triple().length}`); + +// An exception in the receiver propagates; no length is produced. +try { + console.log(failing().length); +} catch (e) { + console.log("caught", (e as Error).message); +} + +console.log(log.join(" | ")); diff --git a/tests/diagnostics/tuples.ts b/tests/diagnostics/tuples.ts index e5cba6aa2..9d8a14ad0 100644 --- a/tests/diagnostics/tuples.ts +++ b/tests/diagnostics/tuples.ts @@ -24,12 +24,6 @@ for (const part of pair) { // An array's runtime length cannot determine a fixed tuple's positions. const copy = [...(["a", 1] as (string | number)[])] as [string, number]; -// The arity constant folds only off side-effect-free receivers. -function mk(): [string, number] { - return ["z", 9]; -} -console.log(mk().length); - // join on ref-element arrays: JS would recursively toString — dishonest. const recs: { id: number }[] = [{ id: 1 }]; console.log(recs.join(",")); diff --git a/tests/harness/__snapshots__/tuples.ts.txt b/tests/harness/__snapshots__/tuples.ts.txt index 600345989..e61606911 100644 --- a/tests/harness/__snapshots__/tuples.ts.txt +++ b/tests/harness/__snapshots__/tuples.ts.txt @@ -26,23 +26,16 @@ tuples.ts:25:15 - error SC1090: spreading a variable-length value into a fixed t | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | -tuples.ts:31:13 - error SC1090: '.length' of a computed tuple expression (the arity is a constant — bind the tuple to a const first) is not supported yet +tuples.ts:29:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet - 30 | } - 31 | console.log(mk().length); - | ^~~~~~~~~~~ - 32 | - -tuples.ts:35:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet - - 34 | const recs: { id: number }[] = [{ id: 1 }]; - 35 | console.log(recs.join(",")); + 28 | const recs: { id: number }[] = [{ id: 1 }]; + 29 | console.log(recs.join(",")); | ^~~~~~~~~~~~~~ - 36 | const tuples: [string, string][] = [["k", "v"]]; + 30 | const tuples: [string, string][] = [["k", "v"]]; -tuples.ts:37:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet +tuples.ts:31:13 - error SC1090: '.join()' on arrays of this element type (number, string, boolean and Date arrays join — unions of those with undefined/null arms too, the units printing empty like JS) is not supported yet - 36 | const tuples: [string, string][] = [["k", "v"]]; - 37 | console.log(tuples.join(";")); + 30 | const tuples: [string, string][] = [["k", "v"]]; + 31 | console.log(tuples.join(";")); | ^~~~~~~~~~~~~~~~ - 38 | // Map elements compile through the same REF storage as function elements. \ No newline at end of file + 32 | // Map elements compile through the same REF storage as function elements. \ No newline at end of file diff --git a/tests/harness/corpus-costs/tuple-length-computed.ts.json b/tests/harness/corpus-costs/tuple-length-computed.ts.json new file mode 100644 index 000000000..bb380e241 --- /dev/null +++ b/tests/harness/corpus-costs/tuple-length-computed.ts.json @@ -0,0 +1 @@ +{ "plain": 2500, "san": 3000 } From 3a1ba891943786cf5b6823a175cf979e1665da18 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 17:04:51 -0500 Subject: [PATCH 2/2] Throw for `.length` of a missing tuple element An unchecked element read of a tuple array (`rows[i].length` with `rows: [number, string][]`) answers the element's optional value, and the computed `.length` path discarded it, so a read past the end printed the tuple's arity. It now holds the receiver and throws Node's TypeError, "Cannot read properties of undefined (reading 'length')", when the element is missing, before answering the arity. The corpus program tuple-length-computed.ts covers a missing element read directly and through a field holding the array. --- .../lowering/expressions/property-access.ts | 44 ++++++++++++++++++- tests/corpus/tuple-length-computed.ts | 17 +++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 0287f0d64..7901c77b6 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3407,12 +3407,54 @@ export function lowerFieldRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressi }; if (plainReadChain(lowerer, expr.expression)) return arity; const receiver = checkerArray ?? lowerer.lowerExpr(expr.expression); + const loc = locOf(expr); + // An unchecked element read (`rows[i].length` with `rows: T[]`) answers + // the element's optional value: a missing element is undefined, and + // reading `length` from it throws Node's TypeError. + if (receiver.type.kind === "union") { + const unionId = receiver.type.unionId; + const held = lowerer.declareHiddenLocal("%tupleLengthReceiver", receiver.type); + let result: IrExpr = arity; + for (const unit of ["undefined", "null"] as const) { + const tag = lowerer.armTag(unionId, unit === "undefined" ? UNDEFINED_T : NULL_T); + if (tag < 0) continue; + result = { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId, + tag, + negated: false, + value: varRef(held.id, receiver.type, loc), + type: BOOL, + loc, + }, + then: nodeThrowExpr( + 1, + "", + `Cannot read properties of ${unit} (reading 'length')`, + F64, + loc, + ), + else_: result, + type: F64, + loc, + }; + } + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: held.id, init: receiver, loc }], + result, + type: F64, + loc, + }; + } return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr: receiver, loc: locOf(expr.expression) }], result: arity, type: F64, - loc: locOf(expr), + loc, }; } } diff --git a/tests/corpus/tuple-length-computed.ts b/tests/corpus/tuple-length-computed.ts index 17a21dc76..6a6eeab6d 100644 --- a/tests/corpus/tuple-length-computed.ts +++ b/tests/corpus/tuple-length-computed.ts @@ -70,4 +70,21 @@ try { console.log("caught", (e as Error).message); } +// An unchecked element read past the end is undefined, so its `length` +// throws, also through a field holding the array. +const rows: [number, string][] = [[1, "a"]]; +let missing = 5; +try { + console.log(rows[missing].length); +} catch (e) { + console.log("missing", String(e)); +} +console.log("present", rows[0].length); +const table: { rows: [number, number][] } = { rows: [] }; +try { + console.log(table.rows[missing - 3].length); +} catch (e) { + console.log("missing field", String(e)); +} + console.log(log.join(" | "));