fix(transformer/legacy-decorator): emit Array for ReadonlyArray<T> in decorator metadata (#22265)

## Summary

`ReadonlyArray<T>` is a TypeScript-only utility type with no runtime
value. The legacy decorator metadata emit currently wraps it in the
runtime guard `typeof (_ref = typeof X !== "undefined" && X) ===
"function" ? _ref : Object`, which always falls through to `Object`
because there is no runtime `ReadonlyArray` binding. tsc resolves this
through the checker and emits `Array`; OXC's existing handling of the
short form `readonly T[]` already emits `Array`, so the generic form is
the only divergent shape.

## The bug

```ts
class Entity {
  @D() shortForm!: readonly string[];           // tsc: Array, OXC: Array  (already correct)
  @D() genericForm!: ReadonlyArray<string>;     // tsc: Array, OXC: Object (bug)
  @D() nestedReadonly!: ReadonlyArray<ReadonlyArray<number>>; // same
}
```

The two surface forms describe the same type but produce different
runtime metadata. Downstream consumers (TypeORM `@Column` array
inference, NestJS Swagger schema, AutoMapper field detection) rely on
the `Array` constructor; `Object` causes silent degradation.

## Fix

In `serialize_type_reference_node`, recognize `ReadonlyArray` by name
when the symbol is unresolved or type-only. Emit `global_array(ctx)`
directly, bypassing the wrapper. A user-shadowed `class ReadonlyArray
{}` would have a value symbol and falls through to the normal
class-handling path, so the shadow case is preserved.

```rust
if ident.name == "ReadonlyArray"
    && symbol_id.is_none_or(|sid| ctx.scoping().symbol_flags(sid).is_type())
{
    return Self::global_array(ctx);
}
```

## Scope

Only `ReadonlyArray` is handled in this PR. `ReadonlyMap`,
`ReadonlySet`, `WeakMap`, `WeakSet`, and `Readonly<Array<T>>` peel are
left for a follow-up because tsc's spec emit for those is less
unambiguous and warrants case-by-case verification. The same
predicate-style approach extends naturally if reviewers want them folded
in.

## Test plan

- [x] 4 unit tests in `decorator_metadata_readonly_array.rs`: bare
ReadonlyArray, nested ReadonlyArray, regression on `readonly T[]` short
form, and a user-shadowed `class ReadonlyArray {}` regression
- [x] New conformance fixture `oxc/metadata/readonly-array/`
- [x] `cargo test -p oxc_transformer` passes (31 unit + 9 integration)
- [x] `cargo run -p oxc_transform_conformance` shows 229 OXC fixtures
still passing, +1 new fixture (`readonly-array`); 0 regressions
- [x] `cargo fmt -p oxc_transformer` and `cargo clippy -p
oxc_transformer --tests --no-deps` clean

AI assistance was used in writing this patch and tests; the contributor
has reviewed and tested locally.

---------

Co-authored-by: Dunqing <dengqing0821@gmail.com>
This commit is contained in:
Kyle CannonandDunqing authored and GitHub committed 2026-05-21 18:19:35 +08:00
1 parent e421ef0f84
commit 69a6ba6353
8 files changed
+135 -2

No files matched your search

@@ -504,6 +504,12 @@ impl<'a> LegacyDecoratorMetadata<'a> {
};
}
// `ReadonlyArray<T>` is a TS-only utility type; tsc emits `Array`. Skip
// when shadowed by any local declaration so the user's binding wins.
if properties.is_empty() && root_ident.name == "ReadonlyArray" && symbol_id.is_none() {
return Self::global_array(ctx);
}
// Type-only references have no runtime binding either.
if Self::is_type_symbol(symbol_id, ctx) {
return Self::global_object(ctx);
@@ -0,0 +1,76 @@
use std::path::Path;
use oxc_allocator::Allocator;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_transformer::{DecoratorOptions, TransformOptions, Transformer};
fn transform_ts(source_text: &str) -> String {
let allocator = Allocator::default();
let source_type = SourceType::ts();
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.errors.is_empty(), "parse errors: {:?}", ret.errors);
let mut program = ret.program;
let scoping = SemanticBuilder::new().build(&program).semantic.into_scoping();
let options = TransformOptions {
decorator: DecoratorOptions { legacy: true, emit_decorator_metadata: true },
..TransformOptions::default()
};
let ret = Transformer::new(&allocator, Path::new(""), &options)
.build_with_scoping(scoping, &mut program);
assert!(ret.errors.is_empty(), "transform errors: {:?}", ret.errors);
Codegen::new()
.with_options(CodegenOptions { single_quote: true, ..CodegenOptions::default() })
.build(&program)
.code
}
#[test]
fn readonly_array_emits_array() {
let output = transform_ts(
r"
function D(): PropertyDecorator { return () => {}; }
class Source { @D() value!: ReadonlyArray<string>; }
",
);
assert!(output.contains("decorateMetadata('design:type', Array)"), "got:\n{output}");
}
#[test]
fn nested_readonly_array_emits_array() {
let output = transform_ts(
r"
function D(): PropertyDecorator { return () => {}; }
class Source { @D() value!: ReadonlyArray<ReadonlyArray<number>>; }
",
);
assert!(output.contains("decorateMetadata('design:type', Array)"), "got:\n{output}");
}
#[test]
fn readonly_short_form_still_emits_array() {
let output = transform_ts(
r"
function D(): PropertyDecorator { return () => {}; }
class Source { @D() value!: readonly string[]; }
",
);
assert!(output.contains("decorateMetadata('design:type', Array)"), "got:\n{output}");
}
#[test]
fn user_shadowed_readonly_array_class_takes_precedence() {
let output = transform_ts(
r"
class ReadonlyArray<T> { value!: T; }
function D(): PropertyDecorator { return () => {}; }
class Source { @D() value!: ReadonlyArray<string>; }
",
);
assert!(
!output.contains("decorateMetadata('design:type', Array)"),
"user-shadowed ReadonlyArray must not be miscompiled to Array. got:\n{output}"
);
}
@@ -1,3 +1,4 @@
mod decorator_metadata;
mod enum_eval;
mod es_target;
mod helper_call;
@@ -1,6 +1,6 @@
commit: 3d34928e
Passed: 237/392
Passed: 237/394
# All Passed:
* babel-plugin-transform-class-static-block
@@ -663,7 +663,7 @@ x Output mismatch
x Output mismatch
# legacy-decorators (9/102)
# legacy-decorators (9/104)
* oxc/accessor/input.ts
x Output mismatch
@@ -1121,6 +1121,31 @@ Unresolved reference IDs mismatch for "babelHelpers":
after transform: [ReferenceId(7), ReferenceId(8), ReferenceId(9), ReferenceId(11), ReferenceId(13), ReferenceId(17), ReferenceId(18), ReferenceId(19), ReferenceId(20), ReferenceId(22), ReferenceId(24)]
rebuilt : [ReferenceId(0), ReferenceId(3), ReferenceId(5), ReferenceId(6), ReferenceId(8), ReferenceId(9), ReferenceId(12), ReferenceId(14), ReferenceId(16)]
* oxc/metadata/readonly-array/input.ts
Bindings mismatch:
after transform: ScopeId(0): ["Source", "dec"]
rebuilt : ScopeId(0): ["Source"]
Reference symbol mismatch for "dec":
after transform: SymbolId(0) "dec"
rebuilt : <None>
Reference symbol mismatch for "dec":
after transform: SymbolId(0) "dec"
rebuilt : <None>
Unresolved references mismatch:
after transform: ["Array", "ReadonlyArray", "babelHelpers"]
rebuilt : ["Array", "babelHelpers", "dec"]
* oxc/metadata/readonly-array-interface-shadow/input.ts
Bindings mismatch:
after transform: ScopeId(0): ["Source", "dec"]
rebuilt : ScopeId(0): ["Source"]
Reference symbol mismatch for "dec":
after transform: SymbolId(2) "dec"
rebuilt : <None>
Unresolved references mismatch:
after transform: ["Object", "babelHelpers"]
rebuilt : ["Object", "babelHelpers", "dec"]
* oxc/metadata/static-anonymous-class-expression/input.ts
Bindings mismatch:
after transform: ScopeId(0): ["A", "Foo", "dec"]
@@ -0,0 +1,9 @@
// Local interface shadows the global `ReadonlyArray` — expect `Object`, not `Array`.
interface ReadonlyArray<T> { custom: T }
declare function dec(target: any, key: string): void;
class Source {
@dec value!: ReadonlyArray<string>;
}
@@ -0,0 +1,4 @@
class Source {
value;
}
babelHelpers.decorate([dec, babelHelpers.decorateMetadata("design:type", Object)], Source.prototype, "value", void 0);
@@ -0,0 +1,6 @@
declare function dec(target: any, key: string): void;
class Source {
@dec items!: ReadonlyArray<string>;
@dec nested!: ReadonlyArray<ReadonlyArray<number>>;
}
@@ -0,0 +1,6 @@
class Source {
items;
nested;
}
babelHelpers.decorate([dec, babelHelpers.decorateMetadata("design:type", Array)], Source.prototype, "items", void 0);
babelHelpers.decorate([dec, babelHelpers.decorateMetadata("design:type", Array)], Source.prototype, "nested", void 0);