Repository navigation
runtime: class X extends Error {} (bare) loses constructor args + SIGABRT on property access #573
Description
Repro
class MyError extends Error {}
const e = new MyError("hello");
console.log("step b: constructed");
console.log("step c: name =", e.name); // perry: SIGABRT (or undefined, depending on layout)
console.log("step d: message =", e.message);bun main.ts:
step b: constructed
step c: name = Error
step d: message = hello
perry main.ts (exits 133 = SIGABRT):
step b: constructed
[crash before step c]
If you give the subclass an explicit constructor(msg) { super(msg); }, both .name and .message work correctly. The issue is the implicit default constructor of a bare-derived extends Error.
Per ECMAScript spec, class MyError extends Error {} is shorthand for class MyError extends Error { constructor(...args) { super(...args); } }. Args should pass through to Error() and produce a real Error with .message set.
Two symptoms, same root cause
- Args dropped: When the implicit ctor doesn't crash,
.nameand.messagecome back undefined — meaning the"hello"arg never reachedError(). - SIGABRT on property access: With slightly different surrounding code,
e.nameaccess crashes withexit 133. Likely the implicit ctor leaves the instance in a partially-initialized state and property reads dereference garbage.
The crash is non-deterministic (different unrelated console.log calls before the access change whether it crashes or returns undefined), which suggests heap-layout sensitivity — classic uninitialized-memory pattern.
Why this matters
Discovered while compiling @bradenmacdonald/s3-lite-client via #551. The package's error hierarchy is:
export class S3Error extends Error {}
export class InvalidArgumentError extends S3Error {}
export class InvalidEndpointError extends S3Error {}
export class InvalidBucketNameError extends S3Error {
constructor(public readonly bucketName: string) { super(`Invalid bucket name: ${bucketName}`); }
}
// ... and moreEvery error thrown by s3-lite arrives at the catch site with e.message === undefined and e.name === undefined because of the bare-derived chain.
This is the canonical pattern across the entire JS error-class ecosystem — every npm package that defines its own error types uses class FooError extends Error {}. drizzle, hono, prisma, axios, every error-typed library.
Suggested implementation
Two fronts:
-
Implicit-ctor arg forwarding: when a class declaration has no explicit constructor and
extends X, generate the equivalent ofconstructor(...args) { super(...args); }. Verify args pass through to the immediate parent's constructor — should already work for non-Error parents per the v0.5.616 cross-module ctor-chain fix; this is the same shape but specifically throughError. -
Error initialization integrity: ensure
Error.prototype.constructor(msg)actually populatesthis.messageandthis.namewhen called from a derived class'ssuper(msg). The crash hint is that Error-typed instances may be shaped differently than regular class instances and the subclass instance layout doesn't match what the message-property accessor expects.
Acceptance
The repro at the top prints name = Error / message = hello. Plus regression covering:
- 2-level bare chain:
class A extends Error {}; class B extends A {}; new B("x").message === "x" - Mixed:
class A extends Error {}; class B extends A { constructor(msg) { super(msg); } } e instanceof Error/e instanceof MyErrorboth truee.stackis a string (or at least not crashing)String(e)returns"Error: hello"per Error.prototype.toString spec
Refs #551
Activity
proggeramlug commentedon May 8, 2026
Fixed in v0.5.664 (320a162).
Three coordinated fixes:
crates/perry-codegen/src/collectors.rsexcludes Error-extending classes from scalar replacement so they take the heap path (the alloca-per-declared-field path doesn't allocate slots formessage/namewhich Error subclasses don't declare).crates/perry-codegen/src/lower_call.rs::lower_new— when a class with no own ctor's parent walk reaches an Error-like built-in, emit the field sets forthis.message = args[0]andthis.name = <kind>directly (matches the SuperCall Error-like arm). Imported classes with real-param ctors still defer to their cross-module ctor.crates/perry-codegen/src/expr.rs::Expr::SuperCall— same Error-init wheneffective_parent_class.constructoris None and the chain reaches Error-like (coversclass B extends A { ctor(m){ super(m); } }where A is bare-extends-Error).Repro from the issue prints
name = Error/message = hello. 2-level bare chain, mixed (explicit ctor descendant of bare-Error), instanceof Error / instanceof MyError, throw/catch all match bun. The s3-liteS3Error → InvalidArgumentErrorshape catches with the right name + message.Note:
String(e)returning"[object Object]"instead of"Error: hello"per Error.prototype.toString is a separate pre-existing issue not addressed here. Same with cross-module TsParamProp + Error (constructor(public statusCode, ...)imported — statusCode comes back undefined). Both tracked separately.fix(hir+codegen): closes #572 #573 #574 — class destructure params + …
fix(codegen): don't scalar-replace an instance whose chain reaches an…
fix(runtime,codegen): spec default Error-init on every dynamic constr…