Skip to content

Decorator metadata: typeof X === "function" runtime guard always falls through to Object for cross-file enums #22228

@kylecannon

Description

@kylecannon

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:

str: String
num: Number

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

str: Object
num: Object

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:

  1. 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.
  2. 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.
  3. 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

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