fix(transformer/legacy-decorator): preserve accessor type annotation for emitDecoratorMetadata (#21966)

Fixes bug 7 of https://github.com/oxc-project/oxc/issues/21922.

## The bug

`@dec accessor x: T` always emitted `design:type = Object` regardless of `T`:

```ts
class Entity {
  @dec accessor name: string = "";   // tsc: design:type = String
  @dec accessor count: number = 0;   // tsc: design:type = Number
}
```

`Reflect.getMetadata("design:type", entity, "name")` returned `Object` instead of `String`. Frameworks reading `design:type` (NestJS Swagger, class-validator, TypeORM column metadata) silently degraded for every typed accessor field.

## Cause

`lower_accessor_properties` (in `enter_class`) rewrites
```ts
@dec accessor x: T = init;
```
into three members — a private storage field, a synthesized getter, and a synthesized setter — and transfers the decorators to the getter so the existing decorator pipeline picks them up. But it dropped the type annotation: the synth getter was constructed with `return_type: NONE`.

The metadata pass runs later and takes the getter branch:

```rust
if method.kind.is_get() {
    (self.serialize_type_annotation(method.value.return_type.as_ref(), ctx), None)
}
```

`return_type` is `None` because lowering discarded it, so `serialize_type_annotation` correctly fell through to `Object` for what it sees as an untyped getter.

The metadata pass's `enter_accessor_property` dispatch exists, but it never fires because the `AccessorProperty` no longer exists by the time traversal reaches the class body.

## Fix

Stop dropping the type annotation. In `lower_accessor_properties`:

```rust
let decorators = std::mem::replace(&mut accessor.decorators, ctx.ast.vec());
let type_annotation = accessor.type_annotation.take();
```

Thread `type_annotation` through `create_accessor_method` to the getter (setter passes `None`). To avoid a post-construction patch on the synthesized `MethodDefinition`, `create_class_method` now takes a `return_type: Option<ArenaBox<TSTypeAnnotation>>` parameter and threads it directly into the function's return-type slot. The other caller (`create_class_constructor_with_params`) passes `None`.

The lowered AST is now self-consistent — the synth getter for `accessor x: T` reads as a getter that returns `T`, which is what it semantically is, and the existing getter-metadata path serializes it correctly.

## Out of scope (architectural limit)

`type Uuid = string; @dec accessor x: Uuid` still emits `Object`, same as for non-accessor properties. Resolving type-alias references to their primitive constituent requires a name-based alias-resolution pass (the same one that `tsc`'s checker provides via `getTypeReferenceSerializationKind`). That's bug 1 of #21922 and a separate change from this fix; flagged here so reviewers don't mistake it for an omission.

## Test plan

- [x] Conformance fixture `oxc/metadata/accessor` covers primitives, untyped (correctly stays `Object`), `string[]` → `Array`, `static accessor`, and computed-key (`@dec accessor ["computed"]: number`)
- [x] `cargo test -p oxc_transformer` — 31 + 5 passed
- [x] `cargo run -p oxc_transform_conformance -- --filter legacy-decorators` — 73 pass / 23 fail (baseline 72/24, +1 new fixture, **0 regressions**)
- [x] `cargo run -p oxc_transform_conformance -- --exec` — accessor exec test verifies `Reflect.getMetadata` returns the correct constructor at runtime
- [x] `cargo fmt -p oxc_transformer`
This commit is contained in:
Dunqing committed 2026-05-07 14:17:45 +00:00
1 parent 5bdf4972b0
commit dd88726b8c
9 files changed
+142 -7

No files matched your search

@@ -48,7 +48,9 @@ mod metadata;
use std::borrow::Cow;
use std::mem;
use oxc_allocator::{Address, CloneIn, GetAddress, TakeIn, UnstableAddress, Vec as ArenaVec};
use oxc_allocator::{
Address, Box as ArenaBox, CloneIn, GetAddress, TakeIn, UnstableAddress, Vec as ArenaVec,
};
use oxc_ast::{NONE, ast::*};
use oxc_ast_visit::{Visit, VisitMut};
use oxc_data_structures::stack::NonEmptyStack;
@@ -359,6 +361,10 @@ impl<'a> LegacyDecorator<'a> {
// Transfer decorators to the getter so legacy decorator transform can process them.
let decorators = std::mem::replace(&mut accessor.decorators, ctx.ast.vec());
// Transfer the type annotation to the getter's return type so that
// `emitDecoratorMetadata` can derive `design:type` from it. Without this,
// the lowered getter is untyped and `design:type` falls through to `Object`.
let type_annotation = accessor.type_annotation.take();
// For computed keys, duplicate the key expression so getter and setter
// each get their own reference:
@@ -426,6 +432,7 @@ impl<'a> LegacyDecorator<'a> {
storage_name,
object_binding,
class_scope_id,
type_annotation,
ctx,
));
@@ -439,6 +446,7 @@ impl<'a> LegacyDecorator<'a> {
storage_name,
object_binding,
class_scope_id,
None,
ctx,
));
}
@@ -467,6 +475,7 @@ impl<'a> LegacyDecorator<'a> {
storage_name: Str<'a>,
object_binding: Option<&BoundIdentifier<'a>>,
class_scope_id: ScopeId,
return_type: Option<ArenaBox<'a, TSTypeAnnotation<'a>>>,
ctx: &mut TraverseCtx<'a>,
) -> ClassElement<'a> {
let is_getter = kind == MethodDefinitionKind::Get;
@@ -544,6 +553,7 @@ impl<'a> LegacyDecorator<'a> {
key,
kind,
params,
return_type,
ctx.ast.vec1(body_stmt),
computed,
is_static,
@@ -203,6 +203,7 @@ pub fn create_class_constructor_with_params<'a>(
),
MethodDefinitionKind::Constructor,
params,
None,
stmts,
false,
false,
@@ -217,6 +218,7 @@ pub fn create_class_method<'a>(
key: PropertyKey<'a>,
kind: MethodDefinitionKind,
params: ArenaBox<'a, FormalParameters<'a>>,
return_type: Option<ArenaBox<'a, TSTypeAnnotation<'a>>>,
stmts: ArenaVec<'a, Statement<'a>>,
computed: bool,
is_static: bool,
@@ -238,7 +240,7 @@ pub fn create_class_method<'a>(
NONE,
NONE,
params,
NONE,
return_type,
Some(ctx.ast.alloc_function_body(SPAN, ctx.ast.vec(), stmts)),
scope_id,
),
@@ -18,6 +18,9 @@ rebuilt : ScopeId(5): Some(ScopeId(0))
Scope parent mismatch:
after transform: ScopeId(4): Some(ScopeId(1))
rebuilt : ScopeId(7): Some(ScopeId(0))
Unresolved reference IDs mismatch for "String":
after transform: [ReferenceId(11), ReferenceId(12)]
rebuilt : [ReferenceId(18)]
semantic Error: tasks/coverage/misc/pass/oxc-1288.ts
Bindings mismatch:
@@ -7150,7 +7150,7 @@ Bindings mismatch:
after transform: ScopeId(3): []
rebuilt : ScopeId(3): ["_class"]
Symbol reference IDs mismatch for "_decorateMetadata":
after transform: SymbolId(16): [ReferenceId(12), ReferenceId(13), ReferenceId(15), ReferenceId(16), ReferenceId(24), ReferenceId(25)]
after transform: SymbolId(16): [ReferenceId(12), ReferenceId(13), ReferenceId(14), ReferenceId(15), ReferenceId(22), ReferenceId(23)]
rebuilt : SymbolId(2): []
Symbol reference IDs mismatch for "dec":
after transform: SymbolId(0): [ReferenceId(1), ReferenceId(2), ReferenceId(3), ReferenceId(4)]
@@ -7159,7 +7159,7 @@ Symbol scope ID mismatch for "_class":
after transform: SymbolId(8): ScopeId(0)
rebuilt : SymbolId(14): ScopeId(3)
Unresolved references mismatch:
after transform: ["DecoratorContext", "Object", "require"]
after transform: ["DecoratorContext", "require"]
rebuilt : ["require"]
semantic Error: tasks/coverage/typescript/tests/cases/compiler/esNextWeakRefs_IterableWeakMap.ts
@@ -1,6 +1,6 @@
commit: 6402dbbf
Passed: 228/376
Passed: 229/377
# All Passed:
* babel-plugin-transform-class-static-block
@@ -647,7 +647,7 @@ x Output mismatch
x Output mismatch
# legacy-decorators (8/95)
# legacy-decorators (9/96)
* oxc/accessor/input.ts
x Output mismatch
@@ -2,7 +2,7 @@ commit: 6402dbbf
node: v24.14.0
Passed: 14 of 16 (87.50%)
Passed: 15 of 17 (88.24%)
Failures:
@@ -0,0 +1,22 @@
import "reflect-metadata";
function dec() {}
class Entity {
@dec accessor name: string = "";
@dec accessor count: number = 0;
@dec accessor flag: boolean = false;
@dec accessor untyped = "x";
@dec accessor list: string[] = [];
@dec static accessor sName: string = "";
@dec accessor ["computed"]: number = 0;
}
const e = new Entity();
expect(Reflect.getMetadata("design:type", e, "name")).toBe(String);
expect(Reflect.getMetadata("design:type", e, "count")).toBe(Number);
expect(Reflect.getMetadata("design:type", e, "flag")).toBe(Boolean);
expect(Reflect.getMetadata("design:type", e, "untyped")).toBe(Object);
expect(Reflect.getMetadata("design:type", e, "list")).toBe(Array);
expect(Reflect.getMetadata("design:type", Entity, "sName")).toBe(String);
expect(Reflect.getMetadata("design:type", e, "computed")).toBe(Number);
@@ -0,0 +1,11 @@
function dec() {}
class Entity {
@dec accessor name: string = "";
@dec accessor count: number = 0;
@dec accessor flag: boolean = false;
@dec accessor untyped = "x";
@dec accessor list: string[] = [];
@dec static accessor sName: string = "";
@dec accessor ["computed"]: number = 0;
}
@@ -0,0 +1,87 @@
function dec() {}
class Entity {
#_name_accessor_storage = "";
get name() {
return this.#_name_accessor_storage;
}
set name(value) {
this.#_name_accessor_storage = value;
}
#_count_accessor_storage = 0;
get count() {
return this.#_count_accessor_storage;
}
set count(value) {
this.#_count_accessor_storage = value;
}
#_flag_accessor_storage = false;
get flag() {
return this.#_flag_accessor_storage;
}
set flag(value) {
this.#_flag_accessor_storage = value;
}
#_untyped_accessor_storage = "x";
get untyped() {
return this.#_untyped_accessor_storage;
}
set untyped(value) {
this.#_untyped_accessor_storage = value;
}
#_list_accessor_storage = [];
get list() {
return this.#_list_accessor_storage;
}
set list(value) {
this.#_list_accessor_storage = value;
}
static #_sName_accessor_storage = "";
static get sName() {
return Entity.#_sName_accessor_storage;
}
static set sName(value) {
Entity.#_sName_accessor_storage = value;
}
#_computed_computed_accessor_storage = 0;
get ["computed"]() {
return this.#_computed_computed_accessor_storage;
}
set ["computed"](value) {
this.#_computed_computed_accessor_storage = value;
}
}
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", String),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity.prototype, "name", null);
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", Number),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity.prototype, "count", null);
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", Boolean),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity.prototype, "flag", null);
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", Object),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity.prototype, "untyped", null);
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", Array),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity.prototype, "list", null);
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", String),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity, "sName", null);
babelHelpers.decorate([
dec,
babelHelpers.decorateMetadata("design:type", Number),
babelHelpers.decorateMetadata("design:paramtypes", [])
], Entity.prototype, "computed", null);