Skip to content

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

Merged
graphite-app[bot] merged 1 commit into
mainfrom
fix-transformer-accessor-emit-decorator-metadata
May 7, 2026
Merged

fix(transformer/legacy-decorator): preserve accessor type annotation for emitDecoratorMetadata#21966
graphite-app[bot] merged 1 commit into
mainfrom
fix-transformer-accessor-emit-decorator-metadata

Conversation

@Dunqing

@Dunqing Dunqing commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes bug 7 of #21922.

The bug

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

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

@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:

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:

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

  • Conformance fixture oxc/metadata/accessor covers primitives, untyped (correctly stays Object), string[]Array, static accessor, and computed-key (@dec accessor ["computed"]: number)
  • cargo test -p oxc_transformer — 31 + 5 passed
  • cargo run -p oxc_transform_conformance -- --filter legacy-decorators — 73 pass / 23 fail (baseline 72/24, +1 new fixture, 0 regressions)
  • cargo run -p oxc_transform_conformance -- --exec — accessor exec test verifies Reflect.getMetadata returns the correct constructor at runtime
  • cargo fmt -p oxc_transformer

Dunqing commented Apr 30, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of the merge queue

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@Dunqing Dunqing changed the title fix(transformer): preserve accessor type annotation for emitDecoratorMetadata fix(transformer/legacy-decorator): preserve accessor type annotation for emitDecoratorMetadata Apr 30, 2026
@codspeed-hq

codspeed-hq Bot commented Apr 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 44 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing fix-transformer-accessor-emit-decorator-metadata (a733fec) with main (73b4f40)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Dunqing Dunqing force-pushed the fix-transformer-accessor-emit-decorator-metadata branch from bea18eb to fc921b8 Compare April 30, 2026 01:51
@camc314 camc314 added the A-transformer Area - Transformer / Transpiler label May 1, 2026
@Dunqing Dunqing force-pushed the fix-transformer-accessor-emit-decorator-metadata branch from b6dbc77 to a733fec Compare May 7, 2026 14:05
@Dunqing Dunqing marked this pull request as ready for review May 7, 2026 14:08
@Dunqing Dunqing requested a review from overlookmotel as a code owner May 7, 2026 14:08
@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label May 7, 2026

Dunqing commented May 7, 2026

Copy link
Copy Markdown
Member Author

Merge activity

…for emitDecoratorMetadata (#21966)

Fixes bug 7 of #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`
@graphite-app graphite-app Bot force-pushed the fix-transformer-accessor-emit-decorator-metadata branch from a733fec to dd88726 Compare May 7, 2026 14:17
@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label May 7, 2026
@graphite-app graphite-app Bot merged commit dd88726 into main May 7, 2026
28 checks passed
@graphite-app graphite-app Bot deleted the fix-transformer-accessor-emit-decorator-metadata branch May 7, 2026 14:22
camc314 added a commit that referenced this pull request May 11, 2026
### 🚀 Features

- 66c9b01 transformer/typescript: Debug_assert that `enum_eval` ran in
semantic (#22252) (Dunqing)
- ffe6475 minifier: Fold `Array` constructor with safe spreads (#22215)
(camc314)

### 🐛 Bug Fixes

- d3d0b18 traverse: Handle `ChainElement::TSNonNullExpression` in
`GatherNodeParts` (#22247) (leaysgur)
- 4e880de transformer/object-rest-spread: Declare temp vars for computed
keys (#22284) (camc314)
- a7c3e22 semantic: Clear member write target for computed keys (#22302)
(camc314)
- 6a8852d codegen: Emit newline after legal-comment orphan flush
(#22304) (Dunqing)
- 5da9fda transformer/explicit-resource-management: Preserve class names
(#22306) (Dunqing)
- b5d970f transformer/explicit-resource-management: Preserve class names
(#22290) (camc314)
- bc54fd4 minifier: Keep function / class names if direct eval is
present in the scope (#22241) (sapphi-red)
- 7a810c0 minifier: Refresh direct eval flags after DCE (#21787)
(Dunqing)
- dd88726 transformer/legacy-decorator: Preserve accessor type
annotation for emitDecoratorMetadata (#21966) (Dunqing)
- 29a3cd7 codegen: Swap mapping/indent order for top-level decls
(#22206) (Dunqing)
- 73b4f40 minifier: Preserve catch binding with direct eval (#22221)
(camc314)
- 0e13d17 minifier: Preserve optional chain base side effects (#22219)
(camc314)
- 0c7c01c transformer/typescript: Inline optional-chain enum member
access (#21834) (Dunqing)
- a6aff7e codegen: Emit block/array/object end mapping at close char
(#22200) (Dunqing)
- a099b03 codegen: Emit call end mapping at `)` position, not past it
(#22199) (Dunqing)
- 5753774 minifier: Cap if-return ternary collapse for firefox (#21841)
(Gurupungav Narayanan)
- 2493bdd codegen: Correct sourcemap end mappings for closing delimiters
(#22001) (Mark Dalgleish)
- 3b385e2 minifier: Bail optimizing `Array` with unknown arg count
(#22188) (camc314)
- 9fa2122 parser: Parse array computed class keys (#22159) (camc314)

### 📚 Documentation

- a4a6892 napi/parser: Correct code comment (#22278) (overlookmotel)
- 9305373 oxc: Update README (#22178) (camc314)

Co-authored-by: Cameron <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-transformer Area - Transformer / Transpiler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants