From a0a6910e4e29963b03eeda26236a6379ab069db1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:16:59 -0700 Subject: [PATCH 1/3] Destructure union sources by narrowing first and joining arm reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object pattern over a union whose arms type a field differently, or do not declare it at all, was refused with SC1031, even though the pattern element's own type is the checker's answer: the join of the arms' declared types, with undefined for an arm that omits the key, or the narrowed arm when a discriminant test or an `in` guard refined the source at the pattern's site. Keep the unionDisc fast path wherever every arm declares the field with one type, so programs that compile today emit identical IR. Otherwise read through the existing keyed join (unionKeyGet): each arm answers its declared type and a record arm that does not declare the key answers undefined, which is exactly the desugar's own property read. Only destructuring passes the new missingFieldAsUndefined flag, so dotted reads keep their current refusals. Narrow first when the un-narrowed pattern does not fit: bind the checker-narrowed view of the source to a hidden local — a trusting arm extraction for one arm, a sub-union re-tag whose excluded arms trap, and the stored layout view for an intersection refinement — and lower the pattern over that view. A rest element over a union source packs each arm's unconsumed fields into a fresh record of the checker's arm-wise rest type, selected by the runtime tag; CopyDataProperties makes a fresh object too, so nothing aliases the source. Shapes that do not spell the rest type still fence. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/property-access.ts | 17 +- .../src/frontend/lowering/lower-stmts.ts | 338 ++++++++++++++++-- .../union-destructuring-join-narrow.ts.json | 6 + .../corpus/union-destructuring-join-narrow.ts | 73 ++++ tests/diagnostics/destructuring-fences.ts | 2 +- .../__snapshots__/destructuring-fences.ts.txt | 16 - .../union-destructuring-join-narrow.ts.json | 1 + 7 files changed, 403 insertions(+), 50 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json create mode 100644 tests/corpus/union-destructuring-join-narrow.ts create mode 100644 tests/harness/corpus-costs/union-destructuring-join-narrow.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 8f41aa93e..4a35b3aee 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -3592,11 +3592,12 @@ export function lowerUnionProperty( * message. The caller (the property/element dispatch) maybeNarrows. */ export function lowerUnionKeyedRead( lowerer: Lowerer, - expr: ts.Expression, + expr: ts.Node, unionId: string, value: IrExpr, key: IrExpr, literalField: string | null, + missingFieldAsUndefined = false, ): IrExpr | null { const def = lowerer.unions.get(unionId); if (!def) return null; @@ -3646,6 +3647,14 @@ export function lowerUnionKeyedRead( } if (!shape.indexValue) { if (literalField === null && shape.fields.length > 0) continue; + // A record arm that does not declare the key at all answers + // UNDEFINED in JS. Destructuring asks for that answer (its own + // desugar reads a missing property as undefined, Node-exact); a + // dotted read keeps refusing until narrowing proves the arm. + if (literalField !== null && missingFieldAsUndefined) { + push(UNDEFINED_T); + continue; + } return null; } if (!pushAnswer(shape.indexValue)) return null; @@ -3690,6 +3699,12 @@ export function lowerUnionKeyedRead( if (!surfaces(declared)) return null; continue; } + if (literalField !== null && !shape.indexValue && missingFieldAsUndefined) { + // The missing-field answer above must surface as the join's + // undefined arm (the backend's keyed-read miss path stores it). + if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null; + continue; + } const ovfShape: IrRecordShape = literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape; if (!recordKeyResultOk(lowerer, ovfShape, type)) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 08f468850..ff51f8c5f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -9,8 +9,12 @@ import { lowerIndexedUnionNumberRead, lowerIndexedUnionRead, } from "./indexed-unions.js"; -import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js"; -import { hostsAll } from "./expressions/hosted-narrowing.js"; +import { + declaredMemberOf, + isPromisePrototypeRead, + lowerUnionKeyedRead, +} from "./expressions/property-access.js"; +import { hostedStorageView, hostsAll } from "./expressions/hosted-narrowing.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; import { lowerForAwaitDynStream, nodeStreamInterfaceSide } from "./dyn-streams.js"; import { lowerForAwaitValueStream } from "./builtins/hash-stream.js"; @@ -3537,6 +3541,34 @@ export function lowerBindingPattern( .get(srcType.unionId) ?.arms.every((arm) => arm.kind === "record" || arm.kind === "object") ) { + const arms = lowerer.unions.get(srcType.unionId)!.arms; + if (!unionObjectPatternFits(lowerer, pattern, arms)) { + // NARROW FIRST: the checker may have refined the union at this + // very site (`if (u.kind === "a") { const { x } = u }`, an `in` + // guard) while the IR value keeps the stored, wider union. Bind + // the narrowed view once and destructure that. + const view = narrowedUnionPatternSource( + lowerer, + pattern, + present, + srcType.unionId, + locOf(pattern), + ); + if (view !== null) { + const tmp = lowerer.declareHiddenLocal("%destrN", view.type); + out.push({ kind: "varDecl", localId: tmp.id, init: view, loc: locOf(pattern) }); + lowerer.lowerBindingPattern( + pattern, + () => ({ kind: "varRef", localId: tmp.id, type: view.type, loc: locOf(pattern) }), + view.type, + isLet, + out, + dynSpell, + allowDynObject, + ); + return; + } + } lowerUnionObjectPattern(lowerer, pattern, srcRef, srcType, isLet, out); return; } @@ -3949,11 +3981,235 @@ function lowerUrlObjectPattern( } } +/** The fresh record for `{ a, ...rest }` over a UNION source: per arm, + * the unconsumed fields copied out at destructure time into a fresh + * record of the checker's arm-wise rest type, selected by the runtime + * tag. Null-safe answer: an arm whose remaining fields do not spell an + * arm of the checker's rest type keeps the named fence. */ +function unionObjectRestValue( + lowerer: Lowerer, + el: ts.BindingElement, + srcRef: () => IrExpr, + srcType: IrType & { kind: "union" }, + arms: readonly IrType[], +): IrExpr { + const loc = locOf(el); + const restT = patternBindingType(lowerer, el.name!); + const fence: (why: string) => never = (why) => lowerer.unsupported("SC1031", el, why); + if (restT === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + // The consumed set is the pattern's literal keys: a computed sibling + // would make it a runtime fact. + const consumed = new Set(); + const pattern = el.parent; + if (ts.isObjectBindingPattern(pattern)) { + for (const sib of pattern.elements.nodes) { + if (sib === el || sib.dotDotDotToken) continue; + const key = sib.propertyName ?? sib.name; + if (key && ts.isComputedPropertyName(key)) { + fence("rest bindings beside computed keys (the consumed set is a runtime fact)"); + } + if (key && (ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key))) { + consumed.add(key.text); + } + } + } + const pieces: { tag: number; value: IrExpr }[] = []; + for (let tag = 0; tag < arms.length; tag++) { + const arm = arms[tag]!; + if (arm.kind !== "record") { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape || shape.indexValue) { + fence( + "rest bindings over index-signature shapes (the undeclared entries would need overflow packing)", + ); + } + const rem = shape.fields.filter((f) => !consumed.has(f.name)); + if (rem.some((f) => f.name.startsWith("%"))) { + fence("rest bindings over accessor shapes (the entries are closures, not data)"); + } + const armRef: IrExpr = { + kind: "unionNarrow", + unionId: srcType.unionId, + tag, + value: srcRef(), + type: arm, + loc, + }; + const packed: IrExpr = lowerer.keyOrder.copy( + { + kind: "recordLit", + fields: rem.map((f) => ({ + name: f.name, + value: lowerer.presenceKeepingCopy( + armRef, + arm.shapeId, + f.name, + { + kind: "recordGet", + obj: armRef, + shapeId: arm.shapeId, + field: f.name, + type: f.type, + loc, + }, + loc, + ), + })), + type: { + kind: "record", + shapeId: lowerer.shapes.intern(rem.map((f) => ({ name: f.name, type: f.type }))), + }, + loc, + }, + arm.shapeId, + ); + const coerced = lowerer.coerceInto(el, packed, restT); + if (!typeEquals(coerced.type, restT)) { + fence( + `rest bindings over union sources (the remaining fields of an arm do not spell an arm of '${lowerer.fmt(restT)}')`, + ); + } + pieces.push({ tag, value: coerced }); + } + let result: IrExpr | null = null; + for (let i = pieces.length - 1; i >= 0; i--) { + const piece = pieces[i]!; + result = + result === null + ? piece.value + : { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: srcType.unionId, + tag: piece.tag, + negated: false, + value: srcRef(), + type: BOOL, + loc, + }, + then: piece.value, + else_: result, + type: restT, + loc, + }; + } + if (result === null) { + fence(`rest bindings over union sources (${NARROW_FIRST})`); + } + return result; +} + +/** The one type every arm declares for `propName`, or null when an arm + * does not declare it (or types it differently, or not at all). */ +function unionPatternCommonType( + lowerer: Lowerer, + arms: readonly IrType[], + propName: string, +): IrType | null { + let common: IrType | null = null; + for (const arm of arms) { + let ft: IrType | undefined; + if (arm.kind === "record") { + ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; + } else if (arm.kind === "object") { + ft = lowerer.classes.get(arm.className)?.fields.get(propName); + } + if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { + return null; + } + common = ft; + } + return common; +} + +/** Whether the unionDisc fast path lowers this pattern over these arms + * unchanged: no rest element, and every element's key folds to one name + * every arm declares with one type. */ +function unionObjectPatternFits( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + arms: readonly IrType[], +): boolean { + for (const el of pattern.elements.nodes) { + if (el.name === undefined) continue; + if (el.dotDotDotToken) return false; + const prop = el.propertyName ?? el.name; + const propName = + ts.isIdentifier(prop) || + ts.isComputedPropertyName(prop) || + ts.isStringLiteralLike(prop) || + ts.isNumericLiteral(prop) + ? patternKeyNameOf(lowerer, prop as ts.PropertyName) + : null; + if (propName === null) return false; + if (unionPatternCommonType(lowerer, arms, propName) === null) return false; + } + return true; +} + +/** The CHECKER-NARROWED view of a destructuring source: `if (u.kind === + * "a") { const { x } = u }` or an `in` guard refines the type at the + * pattern's own site, while the IR value keeps the stored (wider) union + * and the un-narrowed lowering. Bind the value to the narrowed sub-union + * — a trusted arm extraction for one arm, a re-tag with the excluded + * arms as traps for a sub-union — so the pattern reads the arms the + * checker proved. Null when the checker did not narrow, when the + * refinement is an intersection of stored records (hostedStorageView + * keeps that layout), or when nothing was excluded. */ +function narrowedUnionPatternSource( + lowerer: Lowerer, + pattern: ts.ObjectBindingPattern, + value: IrExpr, + fromId: string, + loc: SrcLoc, +): IrExpr | null { + const from = lowerer.unions.get(fromId); + if (!from) return null; + const narrowed = lowerer.irTypeOf(pattern); + if (narrowed.kind === "void" || isUnitType(narrowed)) return null; + const siteArms = + narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed]; + if (!siteArms || siteArms.length === 0) return null; + const allowed = new Set(); + for (const arm of siteArms) { + const tag = lowerer.armTag(fromId, arm); + // Not a stored arm: a checker intersection that refines the stored + // records, which keeps the value's layout. + if (tag < 0) return hostedStorageView(lowerer, value, narrowed, loc); + allowed.add(tag); + } + if (allowed.size === 0 || allowed.size === from.arms.length) return null; + if (allowed.size === 1) { + const tag = [...allowed][0]!; + // The checker proved the tag; the extraction traps on a mismatch + // instead of reading another arm. + return { kind: "unionNarrow", unionId: fromId, tag, value, type: from.arms[tag]!, loc }; + } + const arms = from.arms.filter((_, tag) => allowed.has(tag)); + const subId = lowerer.unions.transform(from, arms); + const helper = lowerer.narrowedRetagHelper(pattern, fromId, subId, loc); + if (!helper) return null; + return { + kind: "call", + callee: helper, + args: [value], + type: { kind: "union", unionId: subId }, + loc, + }; +} + /** An object pattern over a union of records and class instances * (`function f({ kind, path }: Add | Remove)`): each element reads a data - * field every arm declares with one type, through the runtime tag, as - * `value.field` does. Rest elements and fields the arms type differently - * stay fenced. */ + * field through the runtime tag, as `value.field` does — the shared + * `unionDisc` when every arm declares it with one type, else the + * `unionKeyGet` join (an arm that does not declare the key answers + * undefined, as JS reads it). A rest element packs each arm's unconsumed + * fields into a fresh record. Class arms still need same-typed fields. */ function lowerUnionObjectPattern( lowerer: Lowerer, pattern: ts.ObjectBindingPattern, @@ -3966,7 +4222,17 @@ function lowerUnionObjectPattern( for (const el of pattern.elements.nodes) { if (el.name === undefined) continue; if (el.dotDotDotToken) { - lowerer.unsupported("SC1031", el, `rest bindings over union sources (${NARROW_FIRST})`); + // `{ a, ...rest }` over a union: each arm packs its own unconsumed + // fields into a FRESH record (JS's CopyDataProperties makes a fresh + // object too), and the checker's rest type is the union of those + // per-arm packs. The tag decides which pack runs. + lowerer.bindPatternTarget( + el.name, + unionObjectRestValue(lowerer, el, srcRef, srcType, arms), + isLet, + out, + ); + continue; } const prop = el.propertyName ?? el.name; const propName = @@ -3983,35 +4249,43 @@ function lowerUnionObjectPattern( "destructuring with computed keys that do not fold to one property name", ); } - let common: IrType | null = null; - for (const arm of arms) { - let ft: IrType | undefined; - if (arm.kind === "record") { - ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type; - } else if (arm.kind === "object") { - ft = lowerer.classes.get(arm.className)?.fields.get(propName); - } - if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) { - common = null; - break; - } - common = ft; - } - if (common === null) { - lowerer.unsupported( - "SC1031", + const common = unionPatternCommonType(lowerer, arms, propName); + let value: IrExpr; + if (common !== null) { + value = { + kind: "unionDisc", + unionId: srcType.unionId, + field: propName, + value: srcRef(), + type: common, + loc: locOf(el), + }; + } else { + // The unionDisc generalization (`unionKeyGet`): each arm answers + // its own declared type and an arm that does not declare the + // key answers undefined — exactly JS's desugar, which reads a + // missing property as undefined. + const loc = locOf(el); + const joined = lowerUnionKeyedRead( + lowerer, el, - `destructuring '${propName}' from a union whose arms type it differently (every arm must be a record or class with a same-typed field '${propName}' — ${NARROW_FIRST})`, + srcType.unionId, + srcRef(), + { kind: "strLit", value: propName, type: STRING, loc }, + propName, + true, ); + if (joined === null) { + lowerer.unsupported( + "SC1031", + el, + `destructuring '${propName}' from a union whose arms answer it with types that have no combined representation (every arm's '${ + propName + }' must join into one union — ${NARROW_FIRST})`, + ); + } + value = joined; } - let value: IrExpr = { - kind: "unionDisc", - unionId: srcType.unionId, - field: propName, - value: srcRef(), - type: common, - loc: locOf(el), - }; if (el.initializer) value = applyBindingDefault(lowerer, el, value); lowerer.bindPatternTarget(el.name, value, isLet, out); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..99385b8d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-destructuring-join-narrow.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-destructuring-join-narrow.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-destructuring-join-narrow.ts b/tests/corpus/union-destructuring-join-narrow.ts new file mode 100644 index 000000000..85de1faff --- /dev/null +++ b/tests/corpus/union-destructuring-join-narrow.ts @@ -0,0 +1,73 @@ +// Object patterns over a union whose arms type a field differently (or do +// not declare it), narrowed sources, and rest elements over a union. +interface Wide { + kind: "wide"; + value: number; + extra?: string; +} +interface Narrow { + kind: "narrow"; + value: string; + extra?: undefined; +} +type Item = Wide | Narrow; + +// The joined read: `number | string`, `string | undefined`. +function read(item: Item): void { + const { value, extra } = item; + console.log(item.kind, String(value), String(extra)); +} +read({ kind: "wide", value: 7, extra: "e" }); +read({ kind: "narrow", value: "v" }); + +interface Delta { + type: "delta"; + partial: { text: string }; + other: number; +} +interface Final { + type: "final"; + partial: { text: number }; + tail: boolean; +} +interface Bare { + type: "bare"; + other: number; +} +type Wire = Delta | Final | Bare; + +// A rest element over a union packs each arm's own remaining fields. +function rest(event: Delta | Final): string { + const { partial: _partial, ...kept } = event; + return JSON.stringify(kept); +} +console.log(rest({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(rest({ type: "final", partial: { text: 3 }, tail: true })); + +// A discriminant test and an `in` guard narrow the source; the pattern +// reads the arms the checker proved. +function narrowed(event: Wire): string { + if (event.type === "delta") { + const { partial, other } = event; + return `tag:${partial.text}:${other}`; + } + if ("partial" in event) { + const { partial } = event; + return `in:${String(partial.text)}`; + } + return "none"; +} +console.log(narrowed({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(narrowed({ type: "final", partial: { text: 3 }, tail: true })); +console.log(narrowed({ type: "bare", other: 4 })); + +// An `in` guard alone narrows to a SUB-union: both remaining arms keep +// their own field types. +function inOnly(w: Wire): string { + if (!("partial" in w)) return "none"; + const { partial } = w; + return String(partial.text); +} +console.log(inOnly({ type: "delta", partial: { text: "t" }, other: 2 })); +console.log(inOnly({ type: "final", partial: { text: 3 }, tail: true })); +console.log(inOnly({ type: "bare", other: 4 })); diff --git a/tests/diagnostics/destructuring-fences.ts b/tests/diagnostics/destructuring-fences.ts index 0ee60579d..25ee180c2 100644 --- a/tests/diagnostics/destructuring-fences.ts +++ b/tests/diagnostics/destructuring-fences.ts @@ -42,7 +42,7 @@ interface E2 { label: string; } function fromUnion(u: E1 | E2): string { - const { b } = u; // union source whose arms type the field differently: narrow first + const { b } = u; // arms type the field differently: the joined read compiles return String(b); } console.log(fromUnion({ kind: "one", b: "s", count: 1 })); diff --git a/tests/harness/__snapshots__/destructuring-fences.ts.txt b/tests/harness/__snapshots__/destructuring-fences.ts.txt index 41026a7c8..02484d872 100644 --- a/tests/harness/__snapshots__/destructuring-fences.ts.txt +++ b/tests/harness/__snapshots__/destructuring-fences.ts.txt @@ -19,22 +19,6 @@ destructuring-fences.ts:31:9 - error SC1090: reading a property that has only a | ^~~~~~ 32 | console.log(onlyIn); -destructuring-fences.ts:45:11 - error SC1031: destructuring 'b' from a union whose arms type it differently (every arm must be a record or class with a same-typed field 'b' — narrow first: check a discriminant field, or compare with '!== undefined'/'!== null' for unit arms) is not supported yet - - 44 | function fromUnion(u: E1 | E2): string { - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - | ^ - 46 | return String(b); - -destructuring-fences.ts:46:17 - error SC2004: uses of 'b' inherit the blocker on its declaration - - 45 | const { b } = u; // union source whose arms type the field differently: narrow first - 46 | return String(b); - | ^ - 47 | } - - hint: the declaration of 'b' did not compile — fix the diagnostic reported there and these sites clear with it - destructuring-fences.ts:51:7 - error SC1031: object destructuring of non-record values (the source is array-typed) are not supported yet 50 | const xs = [1, 2, 3]; diff --git a/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json new file mode 100644 index 000000000..2117fc7cd --- /dev/null +++ b/tests/harness/corpus-costs/union-destructuring-join-narrow.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 2900 } From 99ebf7501d891d2e04463dd0d92bde3786180e75 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:17:03 -0700 Subject: [PATCH 2/3] Await a union whose promise arm settles a union `Promise | undefined` awaits as Awaited of the operand, which the maybe-promise lowering already computes: it awaits the narrowed promise arm and coerces its settled union, and the non-promise arms take JS's one microtask hop. The await lowering refused any union inner instead, so the promise-or-unit shape now goes through that same path; a union whose promise arm has no combined result keeps the SC1090 fence. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-exprs.ts | 26 ++++++++++++++++--- .../corpus/union-await-promise-arm.ts.json | 6 +++++ tests/corpus/union-await-promise-arm.ts | 24 +++++++++++++++++ .../union-await-promise-arm.ts.json | 1 + 4 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json create mode 100644 tests/corpus/union-await-promise-arm.ts create mode 100644 tests/harness/corpus-costs/union-await-promise-arm.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 89ea52bc5..cc380243c 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -26,7 +26,11 @@ import { checkedPrivateReceiver, localPrivateBrandClass } from "./private-brands import { staticCondition } from "./static-typeof.js"; import { constituentTypes } from "../ts7/checker.js"; import type { Lowerer } from "./lowerer.js"; -import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js"; +import { + lowerMaybePromiseAwait, + maybePromiseUnionOf, + promiseArmedUnionOf, +} from "./maybe-promise.js"; import { checkedClassUnionAssertion } from "./class-assertions.js"; import { BIGINT_T, @@ -668,9 +672,23 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { loc, }; } - if (inner.kind === "union" || inner.kind === "dyn") { - // A union inner would need an arm-wise re-tag into the result - + if (inner.kind === "union") { + // A UNION inner (`Promise | undefined`): the awaited + // result is the checker's own `Awaited` of the operand, which + // the maybe-promise lowering already produces — it awaits the + // narrowed promise arm and coerces its settled union, and the + // unit arms keep JS's one-microtask hop. Only the promise-or- + // unit shape reaches here, so the maybe-promise union is the + // same union with its only data arms being units. + const armed = promiseArmedUnionOf(lowerer, value.type); + if (armed) return lowerMaybePromiseAwait(lowerer, expr, value, armed, loc); + lowerer.unsupported( + "SC1090", + expr, + `awaiting '${lowerer.fmt(value.type)}' (the promise's inner type has no combined result union — await the promise arm after narrowing instead)`, + ); + } + if (inner.kind === "dyn") { lowerer.unsupported( "SC1090", expr, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json new file mode 100644 index 000000000..f62e66a8a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-await-promise-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-await-promise-arm.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-await-promise-arm.ts b/tests/corpus/union-await-promise-arm.ts new file mode 100644 index 000000000..91d9f4485 --- /dev/null +++ b/tests/corpus/union-await-promise-arm.ts @@ -0,0 +1,24 @@ +// Awaiting a union whose promise arm settles a union +// (`Promise | undefined`): the promise arm is awaited, and a +// non-promise arm answers its own value after one microtask turn. +function maybe(flag: boolean): Promise | undefined { + if (!flag) return undefined; + return Promise.resolve(flag ? "value" : null); +} + +async function main(): Promise { + const absent = await maybe(false); + console.log(String(absent)); + const settled = await maybe(true); + console.log(String(settled)); + const nil = await maybe(true); + console.log(nil === null); + + // Awaiting a non-promise arm takes exactly one microtask turn. + const order: string[] = []; + Promise.resolve().then(() => order.push("microtask")); + const hopped = await maybe(false); + order.push(`after:${String(hopped)}`); + console.log(order.join(",")); +} +main(); diff --git a/tests/harness/corpus-costs/union-await-promise-arm.ts.json b/tests/harness/corpus-costs/union-await-promise-arm.ts.json new file mode 100644 index 000000000..6eebf7e03 --- /dev/null +++ b/tests/harness/corpus-costs/union-await-promise-arm.ts.json @@ -0,0 +1 @@ +{ "plain": 3150, "san": 4400 } From 071be693d997c9ef8ac6def69a046c1037fd9815 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:17:10 -0700 Subject: [PATCH 3/3] Support writeFileSync(fd, data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's writeFileSync accepts an open file descriptor where it accepts a path: it writes the data at the descriptor's current offset and leaves the descriptor open, since closeSync owns its lifetime. The static fs surface declared only the path forms, so `fs.writeFileSync(fd, text)` failed type mapping — the compiler was missing a documented overload, not a conversion. Declare the (fd, data) and (fd, data, "utf8" | "utf-8") forms in the node-fallback typings, route the call by the first argument's static f64 type to a new fs.writeFileSyncFd libfn, and implement it as scr_fs_write_file_fd: a write loop over the whole string through the descriptor-validating scr_fs_write_bytes, resuming short writes, with the descriptor operation's errno shape on failure. Co-Authored-By: Claude Opus 5.5 --- .../compiler/ambient/node-fallback/fs.d.ts | 5 +++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../lowering/builtins/filesystem-calls.ts | 36 +++++++++++++++++++ .../lowering/builtins/module-calls.ts | 12 +++++++ .../src/frontend/lowering/surfaces.ts | 4 ++- packages/compiler/src/ir/builtin-effects.ts | 1 + .../compiler/src/ir/builtin-signatures.ts | 5 +++ .../corpus/fs-write-file-sync-fd.ts.json | 6 ++++ packages/runtime/src/scr_lib.c | 19 ++++++++++ packages/runtime/src/scr_lib.h | 4 +++ tests/corpus/fs-write-file-sync-fd.ts | 14 ++++++++ .../fs-write-file-sync-fd.ts.json | 1 + 12 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json create mode 100644 tests/corpus/fs-write-file-sync-fd.ts create mode 100644 tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json diff --git a/packages/compiler/ambient/node-fallback/fs.d.ts b/packages/compiler/ambient/node-fallback/fs.d.ts index 6399558bd..534fad940 100644 --- a/packages/compiler/ambient/node-fallback/fs.d.ts +++ b/packages/compiler/ambient/node-fallback/fs.d.ts @@ -42,6 +42,11 @@ declare module "node:fs" { /* The bare-encoding spelling — the options record's encoding key alone. */ export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void; export function writeFileSync(path: string | URL, data: string | Uint8Array): void; + /* The file-descriptor form: Node writes the whole data to an already + * open descriptor and leaves it open (closeSync owns its lifetime, and + * the write lands at the descriptor's current offset). */ + export function writeFileSync(fd: number, data: string): void; + export function writeFileSync(fd: number, data: string, encoding: "utf8" | "utf-8"): void; export function appendFileSync(path: string | URL, data: string | Uint8Array): void; export function existsSync(path: string | URL): boolean; export function mkdirSync(path: string | URL): void; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 185c85638..9b1172abd 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -556,6 +556,7 @@ export const LIB_FN_SYMS: Record = { "fs.readFileSyncBuf": "scr_fs_read_file_bytes", "fs.readFileSyncBytes": "scr_fs_read_file_bytes", "fs.writeFileSyncBytes": "scr_fs_write_file_bytes", + "fs.writeFileSyncFd": "scr_fs_write_file_fd", "fs.appendFileSyncBytes": "scr_fs_append_file_bytes", "fs.readFdSyncBytes": "scr_fs_read_fd_bytes", // Stats snapshots (scr_lib.c): statSync/lstatSync throw like the other diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 6b5e2d8ae..a308f2216 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -997,6 +997,42 @@ export function lowerFsWriteBytesCall( return null; } +// The writeFileSync(fd, data[, "utf8"]) form — Node accepts a file +// descriptor where it accepts a path (write to a descriptor openSync +// returned, the descriptor staying open: closeSync owns its lifetime). +// Routed by the ARGUMENT's static type, like readFileSync(fd). +export function lowerFsWriteDescriptorCall( + lowerer: Lowerer, + expr: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const supported = 'only string data is supported: writeFileSync(fd, data, "utf8")'; + const dataNode = expr.arguments.nodes[1]!; + const dataIr = lowerer.mapTypeOf(lowerer.typeOf(dataNode)); + if (dataIr?.kind !== "string") { + lowerer.noLowering( + `writeFileSync(fd) of '${dataIr ? lowerer.fmt(dataIr) : lowerer.checker.typeToString(lowerer.typeOf(dataNode))}' data`, + dataNode, + supported, + ); + } + if (expr.arguments.nodes.length > 3) { + lowerer.noLowering( + `writeFileSync(fd) with ${expr.arguments.nodes.length} arguments`, + expr, + supported, + ); + } + const fd = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, F64); + return { + kind: "libCall", + fn: "fs.writeFileSyncFd", + args: [fd, lowerer.lowerExprExpecting(dataNode, STRING)], + type: VOID, + loc, + }; +} + export function lowerFsWriteOptionsCall( lowerer: Lowerer, expr: ts.CallExpression, diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index b21c964f9..bf5223d2f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -14,6 +14,7 @@ import { lowerFsReadSyncCall, lowerFsWriteSyncCall, lowerFsReadDescriptorCall, + lowerFsWriteDescriptorCall, lowerFsEncodedRead, lowerFsPromisesEncodedRead, lowerFsMkdirSyncCall, @@ -331,6 +332,17 @@ export function lowerBuiltinModuleCall( const lowered = lowerFsWriteBytesCall(lowerer, expr, bi, loc); if (lowered) return lowered; } + // writeFileSync(fd, data): Node accepts an open descriptor where it + // accepts a path. Routed by the FIRST ARGUMENT's static type, like + // readFileSync(fd) (before the path forms complete the data type). + if ( + bi.module === "fs" && + bi.member === "writeFileSync" && + expr.arguments.nodes.length >= 2 && + lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "f64" + ) { + return lowerFsWriteDescriptorCall(lowerer, expr, loc); + } if ( bi.module === "fs" && (bi.member === "writeFileSync" || bi.member === "appendFileSync") && diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 97fd3ccda..03b65bd54 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2197,7 +2197,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record< rename: ["fs.checkPath"], readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"], // The bytes-data form and the { mode } options form. - writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync"], + // The bytes-data form, the { mode } options form, and the + // file-descriptor data form. + writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"], appendFileSync: ["fs.appendFileSyncBytes"], // The fs stream members (bytesRead/bytesWritten, pending, path, close). createReadStream: [ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index c8dbc5a61..77fc699ad 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -906,6 +906,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "emitter.setDefaultMaxChk", "fs.readFileSyncBytes", "fs.writeFileSyncBytes", + "fs.writeFileSyncFd", "fs.appendFileSyncBytes", "zlib.inflateSync", "zlib.inflateRawSync", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 06ddb5007..ae3c6514d 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -5262,6 +5262,11 @@ function buildLibFnSignatures() { "fs.mkdtempSync": { argTypes: [STRING], result: STRING }, "fs.accessSync": { argTypes: [STRING, F64], result: VOID }, "fs.readFdSync": { argTypes: [F64, STRING], result: STRING }, + /** The writeFileSync(fd, data) form: a write(2) loop over the whole + * string at the descriptor's current offset, leaving the descriptor + * open (closeSync owns it). Errors carry the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ + "fs.writeFileSyncFd": { argTypes: [F64, STRING], result: VOID }, "fs.readFdSyncBytes": { argTypes: [F64], result: BYTES_U8 }, /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..25021cc12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fs-write-file-sync-fd.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fs-write-file-sync-fd.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e8009d25a..67f18c0d8 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4242,6 +4242,25 @@ double scr_fs_write_str_sync(double fd, ScrStr *data, double position, return wrote; } +/* writeFileSync(fd, data): Node writes the WHOLE data to the descriptor + * and leaves it open (closeSync owns its lifetime), at the descriptor's + * current offset — libuv's uv_fs_write loop, so a pipe's short writes + * resume until the data is gone. Errors keep the fd operation's errno + * shape ("EBADF: bad file descriptor, write"). */ +void scr_fs_write_file_fd(double fd, ScrStr *data) { + char *owned; + const char *bytes = scr_str_utf8(data, &owned); + size_t at = 0; + while (at < data->len) { + double wrote = scr_fs_write_bytes(fd, bytes + at, data->len - at, -1); + /* A throw leaves 0 (or the short count a partial write reported); + * a zero-length write on a nonempty slice would spin, so stop. */ + if (wrote <= 0) break; + at += (size_t)wrote; + } + scr_str_utf8_end(owned); +} + /* fs.readSync(fd, buffer, offset, length[, position]) — the buffer form. * Node validates offset/length against the buffer before reading and * throws ERR_OUT_OF_RANGE; here the checks clamp to the same contract and diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 1d8d91d97..fa88873af 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -289,6 +289,10 @@ ScrStr *scr_fs_read_file(ScrStr *path); ScrStr *scr_fs_realpath(ScrStr *path); ScrStr *scr_fs_realpath_promise(ScrStr *path); void scr_fs_write_file(ScrStr *path, ScrStr *data); +/* writeFileSync(fd, data): a write(2) loop over the whole string at the + * descriptor's current offset; the descriptor stays open (closeSync owns + * it). Errors carry the fd operation's errno shape. */ +void scr_fs_write_file_fd(double fd, ScrStr *data); void scr_fs_append_file(ScrStr *path, ScrStr *data); bool scr_fs_exists(ScrStr *path); void scr_fs_mkdir(ScrStr *path); diff --git a/tests/corpus/fs-write-file-sync-fd.ts b/tests/corpus/fs-write-file-sync-fd.ts new file mode 100644 index 000000000..cfe992598 --- /dev/null +++ b/tests/corpus/fs-write-file-sync-fd.ts @@ -0,0 +1,14 @@ +// writeFileSync(fd, data): Node accepts an open file descriptor where it +// accepts a path, writes the whole data from the descriptor's current +// offset, and leaves the descriptor open. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const scratch = path.join(os.tmpdir(), `scriptc-write-fd-${process.pid}.txt`); +const fd = fs.openSync(scratch, "w"); +fs.writeFileSync(fd, "hello "); +fs.writeFileSync(fd, "world"); +fs.closeSync(fd); +console.log(fs.readFileSync(scratch, "utf8")); +fs.rmSync(scratch); diff --git a/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json new file mode 100644 index 000000000..5bfc37eb6 --- /dev/null +++ b/tests/harness/corpus-costs/fs-write-file-sync-fd.ts.json @@ -0,0 +1 @@ +{ "plain": 3750, "san": 5900 }