Hand back untyped wrapper results at the slot's type, and no-op transpiled prototype resets

- A function adapter whose source returns `any`/`unknown` (a generic
  wrapper's result handed back `as F`) converts the result through the
  checked-dynamic bridge when the slot's result is checkable: a record
  checks its shape, and a Promise keeps its identity and checks its
  payload when it settles. Before, these adapters threw "the call has
  no exact lowering" when called.
- In a class constructor, `Object.setPrototypeOf(this, new.target.prototype)`
  (directly, through a const, or the `if (Object.setPrototypeOf) ... else
  this.__proto__ = ...` form), and `Object.setPrototypeOf(this,
  C.prototype)` in C's own constructor when no class extends C, are
  no-ops: a compiled instance already has that prototype.
- Object.assign between records copies a source field into a wider
  target field (`{ bail: true }` into `bail?: boolean`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-19 23:36:12 -07:00
co-authored by Claude Opus 5.5
parent 7d1d440b7b
commit 0e8b4cf990
8 changed files with 281 additions and 2 deletions
@@ -4,6 +4,7 @@ import { InternalCompilerError } from "../../../errors.js";
import type { IrExpr, IrParam, IrStmt, IrType, SrcLoc } from "../../../ir/ir.js";
import {
canAdaptDynFuncTo,
canDynCheckTo,
DYN,
F64,
isUnitType,
@@ -208,6 +209,19 @@ export function funcCoerceAdapter(
voidRet = toT.ret.kind === "dyn" ? "dyn" : "strand";
}
}
// An untyped result (a generic wrapper's `any` handed back `as F`) takes
// the checked-dynamic extraction to the slot's result: a record checks
// its shape, a Promise keeps its identity and checks its payload when it
// settles.
const checkedRet =
strandRet &&
fromT.ret.kind === "dyn" &&
canDynCheckTo(
toT.ret,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
);
if (checkedRet) strandRet = false;
const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`;
const existing = lowerer.coercions.retags.get(key);
if (existing) return existing;
@@ -307,6 +321,8 @@ export function funcCoerceAdapter(
`a '${lowerer.fmt(fromT)}' function invoked through a '${lowerer.fmt(toT)}' slot (the result cannot convert to '${lowerer.fmt(toT.ret)}' — the checker's loose function compatibility admitted the assignment, but the call has no exact lowering)`,
),
];
} else if (checkedRet) {
body = [{ kind: "return", value: { kind: "dynCheck", value: call, type: toT.ret, loc }, loc }];
} else {
const result = lowerer.coerceToExpected(call, toT.ret);
if (!typeEquals(result.type, toT.ret))
@@ -6699,6 +6699,7 @@ function recordAssignHelper(
const helper = `%obj.assign.${lowerer.arrHofHelpers.size}`;
lowerer.arrHofHelpers.set(key, helper);
const sShape = lowerer.shapes.get(srcShapeId)!;
const tShape = lowerer.shapes.get(targetShapeId)!;
const tT: IrType = { kind: "record", shapeId: targetShapeId };
const sT: IrType = { kind: "record", shapeId: srcShapeId };
const tRef: IrExpr = { kind: "varRef", localId: "t.0", type: tT, loc };
@@ -6713,12 +6714,13 @@ function recordAssignHelper(
type: f.type,
loc,
};
const targetType = tShape.fields.find((x) => x.name === f.name)!.type;
const set: IrStmt = {
kind: "recordSet",
obj: tRef,
shapeId: targetShapeId,
field: f.name,
value: get,
value: typeEquals(targetType, f.type) ? get : lowerer.coerceToExpected(get, targetType),
loc,
};
const present = lowerer.recordFieldPresent(sRef, srcShapeId, f.name, loc);
@@ -7338,7 +7340,12 @@ function lowerObjectStaticCall(
!shapeHasAccessorSlots(sShape) &&
sShape.fields.every((sf) => {
const tf = tShape.fields.find((x) => x.name === sf.name);
return tf !== undefined && typeEquals(tf.type, sf.type);
// A source value converts into a wider target field (`{ bail:
// true }` into `bail?: boolean`).
return (
tf !== undefined &&
(typeEquals(tf.type, sf.type) || lowerer.coercibleValue(sf.type, tf.type))
);
});
if (ok) {
const loc = locOf(call);
@@ -4824,6 +4824,15 @@ function nullishBranches(
export function lowerCondition(lowerer: Lowerer, expr: ts.Expression): IrExpr {
let e: ts.Expression = expr;
while (ts.isParenthesizedExpression(e)) e = e.expression;
// `if (Object.setPrototypeOf)`: the feature test of transpiled Error
// subclasses' prototype reset. Node always has it.
if (
ts.isPropertyAccessExpression(e) &&
!e.questionDotToken &&
e.name.text === "setPrototypeOf" &&
lowerer.isStdlibGlobal(e.expression, "Object")
)
return { kind: "boolLit", value: true, type: BOOL, loc: locOf(e) };
if (ts.isPropertyAccessExpression(e)) {
const presence = lowerPromiseThenPresence(lowerer, e, {
kind: "boolLit",
@@ -1350,6 +1350,7 @@ function lowerLabeled(lowerer: Lowerer, stmt: ts.LabeledStatement): IrStmt | IrS
}
export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt[] | null {
if (ownPrototypeResetStatement(lowerer, stmt)) return { kind: "block", body: [], loc: locOf(stmt) };
if (ts.isClassDeclaration(stmt)) {
if (!stmt.name) lowerer.unsupported("SC1090", stmt, "anonymous local class declarations");
const info = lowerer.lowerClassExpressionInfo(stmt);
@@ -10849,3 +10850,125 @@ function isStringTyped(t: ts.Type): boolean {
if (t.isUnionType()) return ts.constituentTypes(t).every(isStringTyped);
return (t.flags & (ts.TypeFlags.String | ts.TypeFlags.StringLiteral)) !== 0;
}
/** The Error-subclass prototype reset of transpiled code, inside a class
* constructor:
*
* const proto = new.target.prototype;
* if (Object.setPrototypeOf) Object.setPrototypeOf(this, proto);
* else this.__proto__ = proto;
*
* (also `Object.setPrototypeOf(this, new.target.prototype)` and
* `Object.setPrototypeOf(this, C.prototype)` in C's own constructor when no
* class extends C). A compiled instance already has exactly that prototype,
* so each statement is a no-op. True for those statements. */
function ownPrototypeResetStatement(lowerer: Lowerer, stmt: ts.Statement): boolean {
if (ts.isVariableStatement(stmt)) {
const decls = stmt.declarationList.declarations.nodes;
if (decls.length !== 1 || (stmt.declarationList.flags & ts.NodeFlags.Const) === 0) return false;
const decl = decls[0]!;
if (!ts.isIdentifier(decl.name) || !decl.initializer) return false;
const ctor = enclosingConstructor(decl);
if (!ctor || !ownPrototypeRef(lowerer, decl.initializer, ctor)) return false;
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
if (!symbol) return false;
// Every other use of the binding must be one of the reset statements.
let onlyResets = true;
ts.walkPreorder(ctor, (n) => {
if (!onlyResets) return "stop";
if (!ts.isIdentifier(n) || n === decl.name) return undefined;
if (lowerer.checker.getSymbolAtLocation(n) !== symbol) return undefined;
const statement = n.parent?.parent;
if (
!statement ||
!ts.isExpressionStatement(statement) ||
resetTarget(lowerer, statement.expression) !== n
)
onlyResets = false;
return undefined;
});
return onlyResets;
}
if (!ts.isExpressionStatement(stmt)) return false;
const ctor = enclosingConstructor(stmt);
if (!ctor) return false;
const target = resetTarget(lowerer, stmt.expression);
return target !== null && ownPrototypeRef(lowerer, target, ctor);
}
/** The prototype operand of `Object.setPrototypeOf(this, P)` or
* `this.__proto__ = P`, or null. */
function resetTarget(lowerer: Lowerer, expr: ts.Expression): ts.Expression | null {
if (
ts.isCallExpression(expr) &&
expr.arguments.nodes.length === 2 &&
expr.arguments.nodes[0]!.kind === ts.SyntaxKind.ThisKeyword &&
ts.isPropertyAccessExpression(expr.expression) &&
expr.expression.name.text === "setPrototypeOf" &&
lowerer.isStdlibGlobal(expr.expression.expression, "Object")
)
return expr.arguments.nodes[1]!;
if (
ts.isBinaryExpression(expr) &&
expr.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isPropertyAccessExpression(expr.left) &&
expr.left.expression.kind === ts.SyntaxKind.ThisKeyword &&
expr.left.name.text === "__proto__"
)
return expr.right;
return null;
}
/** The class constructor whose body holds `node`, crossing only statements
* and blocks (any nested function rebinds `this`). */
function enclosingConstructor(node: ts.Node): ts.ConstructorDeclaration | null {
for (let cur = node.parent; cur !== undefined; cur = cur.parent) {
if (ts.isConstructorDeclaration(cur)) return cur;
if (ts.isFunctionLike(cur) || (ts.isClassDeclaration(cur) || ts.isClassExpression(cur)) || ts.isSourceFile(cur)) return null;
}
return null;
}
/** Whether `node` denotes the prototype of the object `ctor` constructs:
* `new.target.prototype`, a const holding it, or the class's own
* `C.prototype` when no class extends C. */
function ownPrototypeRef(
lowerer: Lowerer,
node: ts.Expression,
ctor: ts.ConstructorDeclaration,
): boolean {
while (ts.isParenthesizedExpression(node)) node = node.expression;
if (ts.isPropertyAccessExpression(node) && node.name.text === "prototype") {
const base = node.expression;
if (
ts.isMetaProperty(base) &&
base.keywordToken === ts.SyntaxKind.NewKeyword &&
base.name.text === "target"
)
return true;
const cls = ctor.parent;
if (!ts.isIdentifier(base) || !(ts.isClassDeclaration(cls) || ts.isClassExpression(cls))) return false;
const symbol = lowerer.resolveValueSymbol(base);
const info = symbol ? lowerer.classBySymbol.get(symbol) : undefined;
if (!info || lowerer.checker.valueDeclarationOf(symbol!) !== cls) return false;
for (const other of lowerer.classes.values()) {
if (other !== info && lowerer.isSubclassOf(other.def.name, info.def.name)) return false;
}
return true;
}
if (ts.isIdentifier(node)) {
const symbol = lowerer.resolveValueSymbol(node);
const decl = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined;
return (
decl !== undefined &&
ts.isVariableDeclaration(decl) &&
decl.initializer !== undefined &&
ts.isVariableDeclarationList(decl.parent) &&
(decl.parent.flags & ts.NodeFlags.Const) !== 0 &&
enclosingConstructor(decl) === ctor &&
!ts.isIdentifier(decl.initializer) &&
ownPrototypeRef(lowerer, decl.initializer, ctor)
);
}
return false;
}
+14
View File
@@ -103,3 +103,17 @@ function describeApiError(error: Error & { status?: number }): string {
return `${error.message}:${error.status ?? "none"}`;
}
console.log(describeApiError(gateway), describeApiError(new Error("plain")));
// Object.assign into a record whose fields are wider than the source's.
interface BuildFailure {
message: string;
bail?: boolean;
code?: string;
}
function failure(message: string, bail: boolean): BuildFailure {
const err: BuildFailure = { message };
if (bail) Object.assign(err, { bail: true });
return Object.assign(err, { code: "E_BUILD" });
}
for (const f of [failure("stop", true), failure("go", false)])
console.log(f.message, f.bail, f.code);
@@ -0,0 +1,50 @@
// Generic wrappers returning their untyped result `as F`: async functions,
// records with unknown fields, and rejections passing through.
function trace<F extends (...args: any[]) => any>(name: string, fn: F): F {
return ((...args: Parameters<F>) => {
console.log("span", name);
return fn(...args);
}) as F;
}
function traceThis<F extends (...args: any[]) => any>(name: string, fn: F): F {
return function (this: unknown, ...args: Parameters<F>) {
const result: unknown = fn.apply(this, args);
console.log("span", name, typeof result);
return result;
} as F;
}
function lookup(key: string): any {
return key === "set" ? 7 : undefined;
}
const fetchCount = trace("fetchCount", async (base: number): Promise<number> => {
await null;
return base + 1;
});
const fetchUser = traceThis("fetchUser", async (id: string): Promise<{ id: string; admin: boolean }> => ({
id,
admin: id === "root",
}));
const fails = trace("fails", async (why: string): Promise<number> => {
throw new Error(why);
});
const summarize = trace("summarize", (limit = lookup("unset") ?? 2) => ({ kept: limit, dropped: 1 }));
const measure = traceThis("measure", (n: number) => ({ size: n, label: lookup("set") }));
async function main(): Promise<void> {
console.log(await fetchCount(1));
const user = await fetchUser("root");
console.log(user.id, user.admin);
try {
await fails("nope");
} catch (e) {
console.log("caught", e instanceof Error ? e.message : String(e));
}
const s = summarize();
console.log(s.kept, s.dropped, summarize(5).kept);
const m = measure(3);
console.log(m.size, m.label);
const pending = fetchCount(10);
console.log("before");
console.log(await pending);
}
main();
export {};
@@ -0,0 +1,27 @@
// An Error subclass in the shape of a validation library's: the constructor
// resets the prototype to new.target's, as transpiled code does.
export class IssueError extends Error {
constructor(issues) {
super();
this.issues = [];
const actualProto = new.target.prototype;
if (Object.setPrototypeOf) {
Object.setPrototypeOf(this, actualProto);
} else {
this.__proto__ = actualProto;
}
this.name = "IssueError";
this.issues = issues;
}
get message() {
return JSON.stringify(this.issues);
}
get isEmpty() {
return this.issues.length === 0;
}
}
export class StrictIssueError extends IssueError {
get strict() {
return true;
}
}
@@ -0,0 +1,33 @@
// Constructors that reset the instance prototype, the transpiled idiom for
// Error subclasses.
import { IssueError, StrictIssueError } from "./issues.js";
class AppError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.name = "AppError";
}
describe(): string {
return `${this.name}: ${this.message}`;
}
}
class NotFound extends AppError {
constructor(what: string) {
super(`${what} not found`);
Object.setPrototypeOf(this, NotFound.prototype);
this.name = "NotFound";
}
}
const app = new AppError("boom");
const missing = new NotFound("user");
console.log(app.describe(), app instanceof AppError, app instanceof Error);
console.log(missing.describe(), missing instanceof NotFound, missing instanceof AppError);
const issues = new IssueError([{ path: "a" }]);
console.log(issues.message, issues.isEmpty, issues instanceof IssueError, issues.name);
const strict = new StrictIssueError([]);
console.log(strict.isEmpty, strict.strict, strict instanceof IssueError, String(strict instanceof StrictIssueError));
try {
throw new NotFound("page");
} catch (e) {
console.log(e instanceof NotFound ? e.describe() : "other");
}