diff --git a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts index b99c24a15..14cc8ba86 100644 --- a/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts +++ b/packages/compiler/src/frontend/lowering/coercions/function-adapters.ts @@ -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)) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index f9abf2d19..14d5d71d3 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 8b02597ab..61fc0f8d0 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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", diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index eb0d31cbd..e6c6720e0 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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; +} diff --git a/tests/corpus/6404-error-in-and-assign.ts b/tests/corpus/6404-error-in-and-assign.ts index 7003a8ae8..fc6f470e6 100644 --- a/tests/corpus/6404-error-in-and-assign.ts +++ b/tests/corpus/6404-error-in-and-assign.ts @@ -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); diff --git a/tests/corpus/6410-wrapper-untyped-results.ts b/tests/corpus/6410-wrapper-untyped-results.ts new file mode 100644 index 000000000..f8df7683a --- /dev/null +++ b/tests/corpus/6410-wrapper-untyped-results.ts @@ -0,0 +1,50 @@ +// Generic wrappers returning their untyped result `as F`: async functions, +// records with unknown fields, and rejections passing through. +function trace any>(name: string, fn: F): F { + return ((...args: Parameters) => { + console.log("span", name); + return fn(...args); + }) as F; +} +function traceThis any>(name: string, fn: F): F { + return function (this: unknown, ...args: Parameters) { + 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 => { + 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 => { + 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 { + 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 {}; diff --git a/tests/corpus/6411-prototype-reset-constructors/issues.js b/tests/corpus/6411-prototype-reset-constructors/issues.js new file mode 100644 index 000000000..1058d7188 --- /dev/null +++ b/tests/corpus/6411-prototype-reset-constructors/issues.js @@ -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; + } +} diff --git a/tests/corpus/6411-prototype-reset-constructors/main.ts b/tests/corpus/6411-prototype-reset-constructors/main.ts new file mode 100644 index 000000000..65f155e8d --- /dev/null +++ b/tests/corpus/6411-prototype-reset-constructors/main.ts @@ -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"); +}