From 521ba8f1efc92df635fcc27ec2e6e97a098eb0f0 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 28 Sep 2026 09:01:13 -0500 Subject: [PATCH] Preserve JavaScript values beyond null initializers - Keep null-initialized fields and inferred returns in mutable native storage, including inherited resets and saved callbacks. - Convert checked native values with Number using JavaScript coercion and catchable errors. --- .../src/frontend/lowering/lower-calls.ts | 7 +-- .../src/frontend/lowering/lower-classes.ts | 1 + .../compiler/src/frontend/lowering/lowerer.ts | 14 ++++-- packages/compiler/src/frontend/type-mapper.ts | 9 +++- tests/corpus/1592-js-classes.js | 40 +++++++++++++++ .../corpus/3034-arguments-with-parameters.js | 49 +++++++++++++++++++ .../corpus/3037-number-nullish-conversion.js | 8 +++ 7 files changed, 119 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index d619e313..2b9d9f99 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -1006,9 +1006,9 @@ function completeFuncValueArgs( const sig = lowerer.checker.getSignatureFromDeclaration(decl); if (!sig) lowerer.unsupported("SC1090", decl, "this function form"); const retTsType = lowerer.checker.getReturnTypeOfSignature(sig); - // An inferred undefined return in JS can read a mutable checked-value - // field. Keep the returned value instead of erasing it to void. - if (isJsSourceFile(decl.getSourceFile()) && (retTsType.flags & ts.TypeFlags.Undefined) !== 0) return DYN; + // An inferred null/undefined return in JS can read a mutable checked-value + // field. Keep the actual value instead of restricting it to the initializer. + if (isJsSourceFile(decl.getSourceFile()) && (retTsType.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0) return DYN; // A body that always throws infers `never` — as a RETURN type that is // void with a stronger guarantee (`() => never` is assignable to // `() => void`), and throw-only callbacks are ordinary code @@ -5074,6 +5074,7 @@ function lowerNumberConstructorValue(lowerer: Lowerer, argNode: ts.Expression, l }; } if (arg.type.kind === "string") return { kind: "libCall", fn: "num.fromString", args: [arg], type: F64, loc }; + if (arg.type.kind === "dyn") return { kind: "libCall", fn: "dyn.toNumberCoerce", args: [arg], type: F64, loc }; const optionalNumber = lowerOptionalStringNumber(lowerer, arg, loc); if (optionalNumber) return optionalNumber; const scalarUnionNumber = lowerScalarUnionNumber(lowerer, arg, argNode, loc); diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 3b2df7f1..b94fa90f 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -2207,6 +2207,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla // TS-annotated `unknown` fields keep their fence (KEEP NARROW // applies where an annotation could say better). let type = t ? (lowerer.mapTypeOf(t) ?? dynFallbackType(lowerer, assign, t)) : null; + if (t && (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0 && isJsSourceFile(decl.getSourceFile())) type = DYN; if (type) type = jsOpenObjectType(assign, type, lowerer.shapes, lowerer.unions); if (type?.kind === "dyn") { type = inferredEmptyCollectionFieldType(lowerer, decl, name, rhs) ?? type; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 80467117..50234bd7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -935,7 +935,10 @@ function jsFallbackFunctionType(lowerer: Lowerer, node: ts.Node, t: ts.Type): Ir return mapped; }); const retT = lowerer.checker.getReturnTypeOfSignature(sig); - const ret: IrType = retT.flags & ts.TypeFlags.Void ? VOID : lowerer.mapTypeOf(retT) ?? DYN; + const sigDecl = lowerer.checker.signatureDeclaration(sig); + const jsUnitReturn = sigDecl !== undefined && isJsSourceFile(sigDecl.getSourceFile()) && + (retT.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; + const ret: IrType = jsUnitReturn ? DYN : retT.flags & ts.TypeFlags.Void ? VOID : lowerer.mapTypeOf(retT) ?? DYN; return { kind: "func", params, ret }; } @@ -956,7 +959,9 @@ function jsArgumentsFunctionType(lowerer: Lowerer, t: ts.Type): IrType | null { ) return null; const shapes = paramShapes(lowerer, decl.parameters); const retType = lowerer.checker.getReturnTypeOfSignature(sigs[0]!); - const ret = retType.flags & ts.TypeFlags.Void ? VOID : lowerer.mapTypeOf(retType) ?? DYN; + const ret = retType.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined) + ? DYN + : retType.flags & ts.TypeFlags.Void ? VOID : lowerer.mapTypeOf(retType) ?? DYN; return funcTypeFromParamShapes([...shapes, { type: DYN, mode: "arguments" }], ret); } @@ -4978,8 +4983,9 @@ export class Lowerer { irTypeOf(node: ts.Node): IrType { const t = this.typeOf(node); // JS inference can leave mutable fields, accessor parameters and - // locals at undefined. They still carry values; void has no storage. - if (isJsSourceFile(node.getSourceFile()) && (t.flags & ts.TypeFlags.Undefined) !== 0) return DYN; + // locals at null/undefined despite later writes. Keep mutable native + // storage instead of a slot restricted to the initializer's unit value. + if (isJsSourceFile(node.getSourceFile()) && (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0) return DYN; // A never-tainted JS type (neverTaintedJsType) maps — never rides as // f64 — but must not: pre-empt the mapping so the JS fallback below // answers instead. diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index d6ffc129..bee1885d 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2726,8 +2726,13 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const retT = checker.getReturnTypeOfSignature(sig); // `() => never` (a throw-only lambda's inferred type) is assignable to // `() => void` and its calls never produce a value — map the return - // like declaredReturnType does for declarations. - const ret = retT.flags & ts.TypeFlags.Never ? VOID : mapType(retT, ctx); + // like declaredReturnType does for declarations. JS null/undefined + // returns can read mutable fields, so callable values use the same + // checked-native return ABI as their implementations. + const sigDecl = checker.signatureDeclaration(sig); + const jsUnitReturn = sigDecl !== undefined && isJsSourceFile(sigDecl.getSourceFile()) && + (retT.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; + const ret = jsUnitReturn ? DYN : retT.flags & ts.TypeFlags.Never ? VOID : mapType(retT, ctx); if (!ret) return null; return typedRest ? { kind: "func", params, ret, rest: true, restAbi: "typed" } diff --git a/tests/corpus/1592-js-classes.js b/tests/corpus/1592-js-classes.js index 3a4bd30c..301418ba 100644 --- a/tests/corpus/1592-js-classes.js +++ b/tests/corpus/1592-js-classes.js @@ -76,3 +76,43 @@ const dormant = new DormantReturns(); console.log("dormant", dormant.read(), new ShortDormantReturn().read()); const repaired = new RepairedReturns(); console.log("repaired", repaired.dispatch("item"), repaired.selected()); + +// A null initializer does not restrict an inferred JS field to null forever. +class MutableNull { + value = null; + set(value) { this.value = value; } + read() { return this.value; } + get current() { return this.value; } + set current(value) { this.value = value; } +} +class ResetNull extends MutableNull { + value; + constructor(value) { + super(); + console.log('reset', this.value === undefined); + this.value = value; + } +} +class ConstructorNull { + constructor() { this.value = null; } + set(value) { this.value = value; } + read() { return this.value; } +} +const mutableNull = new MutableNull(); +console.log('null initial', mutableNull.read(), mutableNull.current); +mutableNull.set('updated'); +console.log('null written', mutableNull.read(), mutableNull.current); +mutableNull.current = 42; +console.log('accessor', mutableNull.read()); +console.log('numeric field', Number(mutableNull.value)); +const resetNull = new ResetNull('derived'); +console.log('inherited', resetNull.read()); +const readMutableNull = () => mutableNull.value; +function readNullField() { return mutableNull.value; } +const savedNullReader = readNullField; +console.log('readers', readMutableNull(), savedNullReader()); +const constructorNull = new ConstructorNull(); +constructorNull.set('constructor'); +console.log('constructor field', constructorNull.read()); +mutableNull.set(null); +console.log('null reset', readMutableNull(), savedNullReader()); diff --git a/tests/corpus/3034-arguments-with-parameters.js b/tests/corpus/3034-arguments-with-parameters.js index df1c06b3..e99631c5 100644 --- a/tests/corpus/3034-arguments-with-parameters.js +++ b/tests/corpus/3034-arguments-with-parameters.js @@ -29,3 +29,52 @@ function withDefault(value = 41) { withDefault(); withDefault(undefined); withDefault(5); + +function readMutableUnit(first) { + return arguments.length === 1 ? first.value : undefined; +} +const readThroughValue = readMutableUnit; +const mutableUnit = { value: null }; +console.log('unit through value', readThroughValue(mutableUnit)); +mutableUnit.value = 'changed'; +console.log('updated through value', readThroughValue(mutableUnit)); + +// Saved arguments readers keep the implementation's return ABI even when +// JavaScript inference sees only a null/undefined field initializer. +class ArgumentState { + nullable = null; + missing = undefined; + set(value) { this.nullable = value; this.missing = value; } +} +const argumentState = new ArgumentState(); +function readNullable(first) { + console.log('null args', arguments.length, first); + return argumentState.nullable; +} +function readMissing(first) { + console.log('undefined args', arguments.length, first); + return argumentState.missing; +} +const savedNullable = readNullable; +const savedMissing = readMissing; +console.log('initial returns', savedNullable('a', 'b'), savedMissing()); +argumentState.set('updated'); +console.log('updated returns', savedNullable(), savedMissing('c', 'd')); +/** @returns {null} */ +function literalNull(first) { + console.log('literal args', arguments.length, first); + return null; +} +class ArgumentReaders { + nullReader = literalNull; + undefinedReader = function (first) { + console.log('literal undefined args', arguments.length, first); + return undefined; + }; + mutableReader = readNullable; +} +const argumentReaders = new ArgumentReaders(); +const fieldNull = argumentReaders.nullReader; +const fieldUndefined = argumentReaders.undefinedReader; +const fieldMutable = argumentReaders.mutableReader; +console.log('field returns', fieldNull(4, 5), fieldUndefined(6), fieldMutable()); diff --git a/tests/corpus/3037-number-nullish-conversion.js b/tests/corpus/3037-number-nullish-conversion.js index 6ce50c92..cbd280c6 100644 --- a/tests/corpus/3037-number-nullish-conversion.js +++ b/tests/corpus/3037-number-nullish-conversion.js @@ -10,3 +10,11 @@ function nullValue() { effects += "n"; return null; } function undefinedValue() { effects += "u"; return undefined; } console.log(Number(void value()), effects); console.log(Number(nullValue()), Number(undefinedValue()), effects); + +// The same native coercion applies once JS storage carries checked values. +/** @param {unknown} input */ +function numeric(input) { return Number(input); } +console.log(numeric('17'), numeric(true), numeric([]), numeric([3]), numeric({})); +console.log(numeric({ valueOf() { effects += 'c'; return '23'; } }), effects); +try { numeric({ valueOf() { throw new RangeError('numeric hook'); } }); } +catch (error) { console.log(error.name, error.message); }