Skip to content

Decorator metadata: forward / circular class references silently emit Object instead of throwing on TDZ access #22227

@kylecannon

Description

@kylecannon

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

laterRef: Object

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

Metadata

Metadata

Assignees

Labels

A-transformerArea - Transformer / Transpiler

Type

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions