Summary
When a decorated class member is annotated with a class that is forward-referenced (declared later in the same module, or imported through a circular module graph), OXC's legacy decorator metadata transform silently records design:type as Object. Both tsc and babel-plugin-transform-typescript-metadata emit a bare identifier in the same situation and surface a ReferenceError: Cannot access 'X' before initialization at module load.
This is a silent-failure regression vs. both reference implementations.
Why I am filing this separately
Filing per the maintainer's request on a prior thread that grouped this with type-resolution bugs. The mechanism here is different: it is a runtime-guard structure issue, not a type-resolution gap.
Reproduction
source.ts:
import 'reflect-metadata';
function D(): PropertyDecorator { return () => {}; }
class Source {
@D() laterRef!: LaterClass;
}
class LaterClass {
tag = 'later';
}
const inst = new Source();
const t = Reflect.getMetadata('design:type', Source.prototype, 'laterRef');
console.log(`laterRef: ${t?.name ?? String(t)}`);
void LaterClass; // prevent dead-code elimination
Compile with rolldown 1.0.0-rc.16 using transform.decorator.{ legacy: true, emitDecoratorMetadata: true } and run the resulting CommonJS file with Node 22.
Expected behaviour (matches tsc and babel)
ReferenceError: Cannot access 'LaterClass' before initialization
Both tsc and babel-plugin-transform-typescript-metadata emit LaterClass as a bare identifier in the metadata call. At decoration time the binding is in the temporal dead zone, the runtime throws, and the developer sees a clear ordering bug pointing at the exact line.
Actual OXC output
OXC wraps the reference in a runtime guard:
__decorate(
[D(),
__decorateMetadata(
"design:type",
typeof (_ref = typeof LaterClass !== "undefined" && LaterClass) === "function" ? _ref : Object
)],
Source.prototype, "laterRef", void 0
);
var LaterClass = class { tag = "later"; };
var LaterClass is hoisted to undefined. The guard's typeof !== "undefined" test is false, the inner expression evaluates to false, and Object is recorded as design:type. No error, no warning, the program continues with wrong metadata.
Three-way comparison
| Scenario |
tsc |
babel |
OXC |
| forward-ref to class in module |
ReferenceError |
ReferenceError |
Object (silent, no error) |
Why this matters
The typeof X !== "undefined" half of the guard does not prevent a TDZ crash. It silently swallows the TDZ access and substitutes Object. That is exactly the var-hoisting footgun (use-before-init silently produces undefined) that ES2015 introduced TDZ to eliminate. The guard re-creates the hazard the spec was designed to fix.
Downstream impact:
- NestJS DI: a constructor parameter typed as a forward-ref class records
Object as its design:paramtypes entry. The injector treats Object as a valid token, leading to "cannot resolve dependencies (?)" with Object in the chain, or wrong-instance injection if a custom provider is keyed by Object.
- TypeORM / TypedORM: a forward-referenced entity column degrades to JSON column inference, surfacing weeks later as schema drift in migrations.
@automapper/classes: the field is silently dropped because the library treats design:type === Object as "no metadata."
- class-transformer: nested-object hydration falls back to plain object copy.
In all of these, the failure surfaces far from the cause with no stack trace pointing back. tsc and babel both produce an immediate ReferenceError at module load that points at the offending line and is fixable in minutes.
Suggested fix area
The fix appears to live in the runtime-guard emit. The guard takes the shape:
typeof X !== "undefined" && X
For type references where the symbol resolves to a same-module class declaration (i.e. the binding is known to be intended-but-not-yet-initialized), the existence check could be dropped so a bare identifier is emitted and TDZ fires. Ambient bindings, declare-erased imports, and other genuinely-may-not-exist-at-runtime references could keep the guard.
In other words: gate the existence check on whether the binding has a resolved symbol in the current module. Resolved local class binding gets bare identifier; unresolved or declare-only gets the existing guard.
Continuation conversation from #21922
Summary
When a decorated class member is annotated with a class that is forward-referenced (declared later in the same module, or imported through a circular module graph), OXC's legacy decorator metadata transform silently records
design:typeasObject. Bothtscandbabel-plugin-transform-typescript-metadataemit a bare identifier in the same situation and surface aReferenceError: Cannot access 'X' before initializationat module load.This is a silent-failure regression vs. both reference implementations.
Why I am filing this separately
Filing per the maintainer's request on a prior thread that grouped this with type-resolution bugs. The mechanism here is different: it is a runtime-guard structure issue, not a type-resolution gap.
Reproduction
source.ts:Compile with
rolldown1.0.0-rc.16 usingtransform.decorator.{ legacy: true, emitDecoratorMetadata: true }and run the resulting CommonJS file with Node 22.Expected behaviour (matches tsc and babel)
Both
tscandbabel-plugin-transform-typescript-metadataemitLaterClassas a bare identifier in the metadata call. At decoration time the binding is in the temporal dead zone, the runtime throws, and the developer sees a clear ordering bug pointing at the exact line.Actual OXC output
OXC wraps the reference in a runtime guard:
var LaterClassis hoisted toundefined. The guard'stypeof !== "undefined"test is false, the inner expression evaluates tofalse, andObjectis recorded asdesign:type. No error, no warning, the program continues with wrong metadata.Three-way comparison
ReferenceErrorReferenceErrorObject(silent, no error)Why this matters
The
typeof X !== "undefined"half of the guard does not prevent a TDZ crash. It silently swallows the TDZ access and substitutesObject. That is exactly thevar-hoisting footgun (use-before-init silently producesundefined) that ES2015 introduced TDZ to eliminate. The guard re-creates the hazard the spec was designed to fix.Downstream impact:
Objectas itsdesign:paramtypesentry. The injector treatsObjectas a valid token, leading to "cannot resolve dependencies (?)" withObjectin the chain, or wrong-instance injection if a custom provider is keyed byObject.@automapper/classes: the field is silently dropped because the library treatsdesign:type === Objectas "no metadata."In all of these, the failure surfaces far from the cause with no stack trace pointing back. tsc and babel both produce an immediate
ReferenceErrorat module load that points at the offending line and is fixable in minutes.Suggested fix area
The fix appears to live in the runtime-guard emit. The guard takes the shape:
For type references where the symbol resolves to a same-module class declaration (i.e. the binding is known to be intended-but-not-yet-initialized), the existence check could be dropped so a bare identifier is emitted and TDZ fires. Ambient bindings,
declare-erased imports, and other genuinely-may-not-exist-at-runtime references could keep the guard.In other words: gate the existence check on whether the binding has a resolved symbol in the current module. Resolved local class binding gets bare identifier; unresolved or declare-only gets the existing guard.
Continuation conversation from #21922