Skip to content

runtime: class X extends Error {} (bare) loses constructor args + SIGABRT on property access #573

Closed

Description

@proggeramlug

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

  1. Args dropped: When the implicit ctor doesn't crash, .name and .message come back undefined — meaning the "hello" arg never reached Error().
  2. SIGABRT on property access: With slightly different surrounding code, e.name access crashes with exit 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 more

Every 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:

  1. Implicit-ctor arg forwarding: when a class declaration has no explicit constructor and extends X, generate the equivalent of constructor(...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 through Error.

  2. Error initialization integrity: ensure Error.prototype.constructor(msg) actually populates this.message and this.name when called from a derived class's super(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 MyError both true
  • e.stack is a string (or at least not crashing)
  • String(e) returns "Error: hello" per Error.prototype.toString spec

Refs #551

Activity

  1. proggeramlug commented on May 8, 2026

    @proggeramlug
    ContributorAuthor

    Fixed in v0.5.664 (320a162).

    Three coordinated fixes:

    1. crates/perry-codegen/src/collectors.rs excludes Error-extending classes from scalar replacement so they take the heap path (the alloca-per-declared-field path doesn't allocate slots for message / name which Error subclasses don't declare).
    2. 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 for this.message = args[0] and this.name = <kind> directly (matches the SuperCall Error-like arm). Imported classes with real-param ctors still defer to their cross-module ctor.
    3. crates/perry-codegen/src/expr.rs::Expr::SuperCall — same Error-init when effective_parent_class.constructor is None and the chain reaches Error-like (covers class 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-lite S3Error → InvalidArgumentError shape 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.

  2. added a commit that references this issue on May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions