Skip to content

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

Merged
Dunqing merged 2 commits into
oxc-project:mainfrom
kylecannon:fix/decorator-metadata-readonly-array
May 21, 2026
Merged

fix(transformer/legacy-decorator): emit Array for ReadonlyArray<T> in decorator metadata#22265
Dunqing merged 2 commits into
oxc-project:mainfrom
kylecannon:fix/decorator-metadata-readonly-array

Conversation

@kylecannon
Copy link
Copy Markdown
Contributor

@kylecannon kylecannon commented May 8, 2026

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

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.

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

  • 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
  • New conformance fixture oxc/metadata/readonly-array/
  • cargo test -p oxc_transformer passes (31 unit + 9 integration)
  • cargo run -p oxc_transform_conformance shows 229 OXC fixtures still passing, +1 new fixture (readonly-array); 0 regressions
  • 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.

@kylecannon kylecannon force-pushed the fix/decorator-metadata-readonly-array branch 2 times, most recently from 7bbfc9b to 7d1252a Compare May 8, 2026 22:33
@camc314 camc314 added the A-transformer Area - Transformer / Transpiler label May 9, 2026
@kylecannon kylecannon force-pushed the fix/decorator-metadata-readonly-array branch from 7d1252a to 802f7c3 Compare May 11, 2026 16:33
kylecannon and others added 2 commits May 21, 2026 17:56
… decorator metadata

`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.

```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.

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);
}
```

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.

- [x] 4 unit tests in `tests/integrations/decorator_metadata.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
- [x] `cargo run -p oxc_transform_conformance` shows 0 regressions; 229 OXC
  fixtures still pass, plus the new `readonly-array` fixture
- [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.
…e to unresolved refs

Only treat `ReadonlyArray` as the global lib type when the reference is
truly unresolved (`symbol_id.is_none()`). The previous `is_type()`
predicate also matched locally-declared `interface ReadonlyArray` and
`type ReadonlyArray = …`, miscompiling them to `Array` despite the
user's clearly-different local type.

The `class ReadonlyArray {}` case continues to fall through (Class
symbols have `is_type() == false`) and is covered by Kyle's existing
test.

Adds a conformance fixture for the interface-shadow case.
@Dunqing Dunqing force-pushed the fix/decorator-metadata-readonly-array branch from 802f7c3 to 485def7 Compare May 21, 2026 09:59
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 21, 2026

Merging this PR will not alter performance

✅ 52 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing kylecannon:fix/decorator-metadata-readonly-array (485def7) with main (e421ef0)

Open in CodSpeed

Footnotes

  1. 8 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 merged commit 69a6ba6 into oxc-project:main May 21, 2026
36 checks passed
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.

3 participants