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.
This commit is contained in:
Chris Tate
2026-09-28 09:01:13 -05:00
committed by GitHub
parent 43e29623ba
commit 521ba8f1ef
7 changed files with 119 additions and 9 deletions
@@ -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);
@@ -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;
@@ -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.
@@ -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" }
+40
View File
@@ -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());
@@ -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());
@@ -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); }