Repository navigation
class declarations in function bodies share one class across evaluations — effect's makeException masks every error as TimeoutException #6465
Description
Summary
A class declaration inside a function body gets one shared compile-time class — every factory evaluation returns the same class object, so captured field initializers, Object.assign(C.prototype, ...) writes, and instanceof all cross-contaminate between evaluations. #6449 fixed exactly this for class expressions (const C = class {...}, ClassExprFresh); the declaration form has the same disease.
Where it bites
effect/src/internal/core.ts makeException — the factory behind ALL of effect's core exception classes:
const makeException = (proto, tag) => {
class Base extends YieldableError {
readonly _tag = tag // captured field init
}
Object.assign(Base.prototype, proto) // per-kind TypeId symbol etc.
;(Base.prototype as any).name = tag
return Base as any
}
export const RuntimeException = makeException({...}, "RuntimeException")
export const InterruptedException = makeException({...}, "InterruptedException")
...
export const TimeoutException = makeException({...}, "TimeoutException")Under perry every one of these classes is the SAME class: any error raised through them reports _tag/name "TimeoutException" (last evaluation wins). In the effect-compiled-experiment web.ts, the actual startup failure (still unknown!) is masked as TimeoutException: An error has occurred — the real error's _tag was overwritten and its message lost. Every Effect.catchTag/isInterruptedException-style dispatch in any effect app is unreliable under this bug.
Minimal repro (no effect)
class YieldableErr extends Error {}
const makeException = (proto: object, tag: string): any => {
class Base extends YieldableErr { readonly _tag = tag }
Object.assign(Base.prototype, proto)
;(Base.prototype as any).name = tag
return Base
}
const RuntimeException = makeException({ kindR: true }, "RuntimeException")
const InterruptedException = makeException({ kindI: true }, "InterruptedException")
const TimeoutException = makeException({ kindT: true }, "TimeoutException")
const r: any = new RuntimeException()
console.log(r._tag) // node: RuntimeException | perry: TimeoutException
console.log(RuntimeException.prototype !== TimeoutException.prototype) // node: true | perry: false
console.log(r instanceof TimeoutException) // node: false | perry: true
console.log(Object.keys(r).join("|")) // node: _tag | perry: _tag|__perry_cap_8Full matrix (node → perry):
| check | node | perry |
|---|---|---|
r._tag / i._tag / t._tag |
Runtime / Interrupted / Timeout | all TimeoutException |
name per class |
per-tag | all TimeoutException |
| protos distinct | true | false |
Object.assign(proto, {kind}) visible on instance |
true | false (all kinds lost) |
cross-instanceof |
false | true |
| instance own keys | _tag |
_tag|__perry_cap_8 (internal capture slot leaks as a property) |
Relation to #6449
#6449 (ClassExprFresh) already builds per-evaluation class objects for class expressions with statics/heritage/captured args. Class declarations in function scope need the same routing — they are hoisted within their block, but each evaluation of the enclosing function must produce a fresh class object with its own prototype identity and its own captured-arg environment.
Found while driving effect-compiled-experiment web.ts to node parity: layers so far — #6449 (class-expr statics) → #6460 (GetIterator on class decl) → #6463 (init-order back-edges) → this.
Activity
fix(hir): per-evaluation captures for class declarations in function …
proggeramlug commentedon Jul 17, 2026
Fixed by #6470 (
fix(hir): per-evaluation captures for class declarations in function bodies), merged to main. effect'smakeExceptionfactory now produces a fresh class per evaluation instead of one shared compile-time class, so errors are no longer cross-contaminated / masked asTimeoutException. The effect apps run byte-identical to Node.Follow-ups from #6470: the boxed-capture regression #6497 has already been resolved; the narrower captured-param class-
.nameregression is tracked separately in #6482. Closing this original issue as resolved.fix(classes): forward-declared lexical captures in class members (#6523…