Summary
When OXC's legacy decorator metadata transform encounters a type reference it cannot statically classify as a class (the dominant case being a cross-file imported enum), it wraps the reference in a runtime guard:
typeof (_ref = typeof X !== "undefined" && X) === "function" ? _ref : Object
This guard is structurally wrong for enums. TypeScript enums lower to plain objects ({ A: 'a', B: 'b' }) where typeof === "object", never "function". So the guard is a guaranteed fallthrough to Object for every cross-file enum used as a decorated property type.
The result: every @D() x: SomeImportedEnum records design:type as Object, and downstream consumers (NestJS Swagger, AutoMapper, class-validator's implicit-enum path, TypedORM enum columns) lose the metadata they need, making migrations from esbuild w/ babel-plugin-transform-typescript-metadata or typescript even more difficult.
Why this is a different bug than the type-alias and intersection cases
The closest related issues classify type-reference resolution as the gap (no type checker, no alias / intersection resolution). That framing does not describe this case.
The bug here is not "OXC failed to resolve the type to its underlying primitive." It is "the runtime guard OXC emits is structurally incompatible with enum bindings." Even with no type information at all, dropping the guard and emitting the bare identifier (which is what babel-plugin-transform-typescript-metadata does) produces a more useful runtime value than Object.
Reproduction
enums.ts:
export enum StringEnum { A = 'a', B = 'b' }
export enum NumericEnum { X = 1, Y = 2 }
source.ts:
import 'reflect-metadata';
import { StringEnum, NumericEnum } from './enums';
function D(): PropertyDecorator { return () => {}; }
class Source {
@D() str!: StringEnum;
@D() num!: NumericEnum;
}
const inst = new Source();
for (const f of ['str', 'num']) {
const t = Reflect.getMetadata('design:type', Source.prototype, f);
console.log(`${f}: ${t?.name ?? String(t)}`);
}
Note: the cross-file import is essential. Same-file enums dodge the guard because OXC inlines the constant value at the reference site.
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 output
tsc emits the primitive constructor classified through getTypeReferenceSerializationKind:
babel-plugin-transform-typescript-metadata emits the enum binding directly with no guard, and Reflect.getMetadata returns the enum object itself:
str: [object Object] // the enum object { A: 'a', B: 'b' }
num: [object Object] // the enum object { X: 1, Y: 2 }
The babel form is "wrong but useful": consumers like NestJS Swagger introspect the enum members via Object.values(t) to recover the value list and produce a correct schema. AutoMapper, class-transformer, and TypedORM accept it for similar reasons.
Actual OXC output
The transformed output:
__decorate(
[D(),
__decorateMetadata(
"design:type",
typeof (_ref = typeof StringEnum !== "undefined" && StringEnum) === "function" ? _ref : Object
)],
Source.prototype, "str", void 0
);
At runtime StringEnum is { A: 'a', B: 'b' }. typeof === "object", so the guard picks the Object branch unconditionally.
Three-way comparison
| Transformer |
design:type for str: StringEnum (cross-file) |
tsc 5.9.3 |
String |
babel-plugin-transform-typescript-metadata 0.4.0 |
the enum object { A: 'a', B: 'b' } |
| OXC (rolldown 1.0.0-rc.16) |
Object |
OXC is the only transformer producing Object here. It is strictly worse than babel, not equivalent.
Why this matters
For consumers that read design:type:
- NestJS Swagger:
@ApiProperty() without an explicit enum: argument introspects design:type. With babel's enum object the schema is generated correctly via Object.values. With OXC's Object the schema collapses to type: "object" with no enum: field, breaking generated client SDK enums.
@automapper/classes: explicitly drops fields whose design:type === Object. Cross-file enum fields disappear from mapper output silently.
- TypeORM / TypedORM: enum column inference falls back to JSON or string columns. Manual
{ enum: MyEnum } is required at every site.
- class-validator:
@IsEnum() takes an explicit argument, so unaffected. @ValidateNested() is unaffected for enums.
The dominant pattern in real codebases is shared-enums-in-a-common-package imported by many DTOs. Migrating from a babel pipeline to OXC silently degrades schemas across the codebase wherever this pattern appears.
What I think you are protecting against, and why dropping the guard is still the right call
The guard exists for cases where the runtime binding may genuinely be undefined (ambient declare types, erased imports, type-only references). For those, falling through to Object is the safe choice.
For value bindings imported from other modules, the binding is undefined only when the module graph is broken (a separate failure that should surface as a ReferenceError, not a metadata bug). For enum bindings specifically, the runtime value is always a non-function object, so the typeof === "function" test is the wrong shape regardless.
A type-aware fix that distinguishes "imported enum" from "imported class" requires cross-module resolution, which is not how OXC's per-file decorator transform is structured today. I am not asking for that.
What I am asking for is a re-evaluation of the guard's branch logic. Concretely, one of:
- Match babel's emit: drop the guard for type references that resolve to value-binding imports, and emit the bare identifier. Same hazard surface as babel, but produces useful runtime values for the dominant case.
- Broaden the guard: change
typeof === "function" ? X : Object to also accept non-undefined "object" bindings, returning X instead of Object. Preserves the existence check while producing the babel-equivalent runtime value.
- Acknowledge the structural distinction explicitly: if neither (1) nor (2) is acceptable, document this case as a known limitation rather than grouping it with type-resolution close-rationales. The structural difference matters for consumers planning around it.
Continuation conversation from #21922
Summary
When OXC's legacy decorator metadata transform encounters a type reference it cannot statically classify as a class (the dominant case being a cross-file imported enum), it wraps the reference in a runtime guard:
This guard is structurally wrong for enums. TypeScript enums lower to plain objects (
{ A: 'a', B: 'b' }) wheretypeof === "object", never"function". So the guard is a guaranteed fallthrough toObjectfor every cross-file enum used as a decorated property type.The result: every
@D() x: SomeImportedEnumrecordsdesign:typeasObject, and downstream consumers (NestJS Swagger, AutoMapper, class-validator's implicit-enum path, TypedORM enum columns) lose the metadata they need, making migrations from esbuild w/babel-plugin-transform-typescript-metadataor typescript even more difficult.Why this is a different bug than the type-alias and intersection cases
The closest related issues classify type-reference resolution as the gap (no type checker, no alias / intersection resolution). That framing does not describe this case.
The bug here is not "OXC failed to resolve the type to its underlying primitive." It is "the runtime guard OXC emits is structurally incompatible with enum bindings." Even with no type information at all, dropping the guard and emitting the bare identifier (which is what
babel-plugin-transform-typescript-metadatadoes) produces a more useful runtime value thanObject.Reproduction
enums.ts:source.ts:Note: the cross-file import is essential. Same-file enums dodge the guard because OXC inlines the constant value at the reference site.
Compile with
rolldown1.0.0-rc.16 usingtransform.decorator.{ legacy: true, emitDecoratorMetadata: true }and run the resulting CommonJS file with Node 22.Expected output
tscemits the primitive constructor classified throughgetTypeReferenceSerializationKind:babel-plugin-transform-typescript-metadataemits the enum binding directly with no guard, andReflect.getMetadatareturns the enum object itself:The babel form is "wrong but useful": consumers like NestJS Swagger introspect the enum members via
Object.values(t)to recover the value list and produce a correct schema. AutoMapper, class-transformer, and TypedORM accept it for similar reasons.Actual OXC output
The transformed output:
At runtime
StringEnumis{ A: 'a', B: 'b' }.typeof === "object", so the guard picks theObjectbranch unconditionally.Three-way comparison
design:typeforstr: StringEnum(cross-file)tsc5.9.3Stringbabel-plugin-transform-typescript-metadata0.4.0{ A: 'a', B: 'b' }ObjectOXC is the only transformer producing
Objecthere. It is strictly worse than babel, not equivalent.Why this matters
For consumers that read
design:type:@ApiProperty()without an explicitenum:argument introspectsdesign:type. With babel's enum object the schema is generated correctly viaObject.values. With OXC'sObjectthe schema collapses totype: "object"with noenum:field, breaking generated client SDK enums.@automapper/classes: explicitly drops fields whosedesign:type === Object. Cross-file enum fields disappear from mapper output silently.{ enum: MyEnum }is required at every site.@IsEnum()takes an explicit argument, so unaffected.@ValidateNested()is unaffected for enums.The dominant pattern in real codebases is shared-enums-in-a-common-package imported by many DTOs. Migrating from a babel pipeline to OXC silently degrades schemas across the codebase wherever this pattern appears.
What I think you are protecting against, and why dropping the guard is still the right call
The guard exists for cases where the runtime binding may genuinely be
undefined(ambientdeclaretypes, erased imports, type-only references). For those, falling through toObjectis the safe choice.For value bindings imported from other modules, the binding is
undefinedonly when the module graph is broken (a separate failure that should surface as aReferenceError, not a metadata bug). For enum bindings specifically, the runtime value is always a non-function object, so thetypeof === "function"test is the wrong shape regardless.A type-aware fix that distinguishes "imported enum" from "imported class" requires cross-module resolution, which is not how OXC's per-file decorator transform is structured today. I am not asking for that.
What I am asking for is a re-evaluation of the guard's branch logic. Concretely, one of:
typeof === "function" ? X : Objectto also accept non-undefined"object"bindings, returningXinstead ofObject. Preserves the existence check while producing the babel-equivalent runtime value.Continuation conversation from #21922