Skip to content

fix: #740 — Effect TaggedError class-extends-factory(captured) pattern#783

Merged
proggeramlug merged 3 commits into
mainfrom
fix/740-effect-tagged-error
May 15, 2026
Merged

fix: #740 — Effect TaggedError class-extends-factory(captured) pattern#783
proggeramlug merged 3 commits into
mainfrom
fix/740-effect-tagged-error

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

class ParseError extends TaggedError("ParseError")<...> in Effect's ParseResult.ts__init threw TypeError: value is not a function at module init. Two broken seams when a class is declared inside a factory function with captured values:

  1. lower_class_from_ast (anonymous class expressions used by class extends Error<{}> { ... }) silently skipped the __perry_cap_<id> capture-synthesis machinery that lower_class_decl ran for named declarations.
  2. The inliner replaced factory calls like makeFactory("MyTag") with ClassRef("Inner") while discarding the param bindings, so the captured value never reached the new Cls() site.
  3. Effect additionally registers the parent dynamically via RegisterClassParentDynamic; even with captures wired, codegen's static parent-ctor walk couldn't find the specialized parent.

Fix:

  • Extract synthesize_class_captures helper in perry-hir/src/lower_decl.rs and call it from both lower_class_decl and lower_class_from_ast so anon class expressions get the same __perry_cap_* field/param synthesis. (Also covers field initializers like _tag = tag and computed-key expressions, which previously read garbage at runtime.)
  • New specialize_captured_class_factories pre-pass in perry-transform/src/inline.rs that clones the captured class per call site with captures baked in as constants; recurses into RegisterClassParentDynamic.parent_expr and hoists the resolved parent onto the child's static extends_name; drops empty synthesized middle ctors so the real ancestor ctor runs.
  • perry-codegen/src/lower_call.rs: bind ctor params before apply_field_initializers_recursive so field-init expressions see the captured value at construction time.
  • Supporting changes in lower.rs, destructuring.rs, lower/expr_new.rs, analysis.rs (let-binding class-alias chain at HIR-lower time + helper visibility bump).

Refs #321 (Effect end-to-end blocker post-#711).

Test plan

  • Standalone repro (TaggedError factory → ParseError extends TaggedError("ParseError")<...>) matches node --experimental-strip-types byte-for-byte:
    [3] ParseError defined
    [4] pe._tag: ParseError
    [5] pe.message: parse error: {"kind":"bad","detail":42}
    
  • cargo test --release -p perry-hir --lib — 41/41 pass
  • cargo build --release -p perry-hir -p perry-transform -p perry-codegen -p perry green
  • Gap suite unchanged: 33 pass / 2 pre-existing failures (test_gap_console_methods, test_gap_regexp_advanced)
  • Full Effect end-to-end repro from Tracking: Effect framework end-to-end compat (post-#309 / #310) #321 (bun add effect@3.21.2 && perry compile test_bare.ts -o /tmp/out && /tmp/out) — not validated in this PR; the standalone repro from the issue is the validation gate. Worth verifying before closing Tracking: Effect framework end-to-end compat (post-#309 / #310) #321.

Root cause: a class declared inside a factory function (`class Inner {
_tag = tag }` where `tag` is the factory's param) participates in two
broken seams:
  - lower_class_decl synthesizes the capture machinery (__perry_cap_<id>
    ctor params + matching fields + field-init rewrite), but
    lower_class_from_ast (used for class EXPRESSIONS like
    `class extends Error { ... }`) did not, so anon-class-expr
    captures were silently dropped.
  - When the factory function returns the class (`return Inner` or
    `return O.BaseEffectError`), the inliner replaces the call with
    `ClassRef(...)` and discards the parameter bindings — so even the
    named-class path lost the captured value at the call site.

Fix has two parts:

1. crates/perry-hir/src/lower_decl.rs — extract the Issue #212 capture
   machinery into a shared `synthesize_class_captures` helper and call
   it from BOTH `lower_class_decl` and `lower_class_from_ast`, so
   anon class expressions get the same __perry_cap_<outer_id> ctor
   params + field rewriting as named declarations. Also collect captures
   from field initializers / computed keys (not just method bodies)
   inside the helper.

2. crates/perry-transform/src/inline.rs — add a pre-pass
   `specialize_captured_class_factories` that, for each
   `Let { init: Call(FuncRef(f), args) }` where `f` is a single-
   expression factory that returns a captured class (three body shapes
   supported: direct `Return ClassRef(C)`; `Let X = ClassRef(C); ...; Return LocalGet(X)`;
   `Let O = New(__AnonShape, [ClassRef(C),...]); ...; Return O.<field>`):
   - Clone C → C__inline_<n> with each __perry_cap_<outer_id> ctor
     param's id substituted by the corresponding call arg in
     method/getter/setter/field-init/computed-key bodies, then drop
     the cap ctor param, the cap field, and the ctor-body assignment.
   - If the cloned ctor ends up with no params and an empty body, drop
     the ctor entirely so lower_new's parent-walk continues to the
     first real ancestor ctor (otherwise the empty middle-class ctor
     would short-circuit BaseError's `this.issue = opts.issue`).
   - Replace the Call site with `ClassRef(C__inline_<n>)`.
   - When the rewrite lands inside `RegisterClassParentDynamic`'s
     parent_expr, hoist the resolved `extends_name` (and `extends`
     class-id) onto the child class statically so codegen's field-
     initializer-recursive walk and parent-ctor inlining find the
     specialized class.

3. crates/perry-codegen/src/lower_call.rs — bind ctor params BEFORE the
   `apply_field_initializers_recursive` call (saving/restoring locals
   around the whole block), so field initializers rewritten to read
   `LocalGet(__perry_cap_<n>)` see the actual ctor param value instead
   of falling through to the 0.0 soft-fallback.

4. crates/perry-hir/src/lower.rs + destructuring.rs + lower/expr_new.rs —
   register let-binding aliases at HIR-lowering time
   (`const Cls = Inner` style direct-ClassRef bindings) and follow
   LocalGet alias chains, so `Expr::New { class_name: <alias> }` can
   resolve through to the underlying class's captures. (Useful for
   intra-module rebinds that never went through a factory.)

5. crates/perry-hir/src/analysis.rs — make `remap_local_ids_in_expr`
   pub(crate) so the capture-machinery extraction can reach it from
   `lower_decl`.

Standalone repro from issue text matches node byte-for-byte:
  [3] ParseError defined
  [4] pe._tag: ParseError
  [5] pe.message: parse error: {"kind":"bad","detail":42}

Three smaller probes (factory returning class via direct return / local-
binding / object-literal-wrapping) all match node.

Gap suite: 33/35 pass — no regression from main (same two pre-existing
failures: test_gap_console_methods, test_gap_regexp_advanced).
Targeted cargo test suites (perry-hir, perry-runtime, perry-codegen,
perry-transform) all pass.
Resolves the failing `lint` check on PR #783 (cargo fmt --all --check).
Pure rustfmt reflow — no token or logic changes.
@proggeramlug proggeramlug merged commit 999a86d into main May 15, 2026
1 check passed
@proggeramlug proggeramlug deleted the fix/740-effect-tagged-error branch May 15, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant