Skip to content

fix(es/typescript): Treat const variable references as enum constants - #12101

Open
Baltasar Blanco (baltasarblanco) wants to merge 11 commits into
swc-project:mainfrom
baltasarblanco:fix/11715-ts-enum-const-var-folding
Open

fix(es/typescript): Treat const variable references as enum constants#12101
Baltasar Blanco (baltasarblanco) wants to merge 11 commits into
swc-project:mainfrom
baltasarblanco:fix/11715-ts-enum-const-var-folding

Conversation

@baltasarblanco

@baltasarblanco Baltasar Blanco (baltasarblanco) commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Description:

EnumValueComputer::compute_rec gates its identifier arm on ctxt == unresolved_ctxt, which only matches references to sibling enum members. A reference to a real const binding is a resolved binding, so it fell through to the Opaque catch-all.

Two consequences. The reported one is emit shape:

const foo = "aThisIs";
enum E { A = foo, B = "bThisIs" }
// before — three properties, because the reverse mapping assigns twice
E[E["A"] = foo] = "A";
// after, and what tsc emits
E["A"] = "aThisIs";

The second is not in the issue and is worse — numeric members silently become undefined:

const foo = 1;
enum E { A = foo, B, C }
before -> { A: 1, "1": "A", "undefined": "C" }
after  -> { A: 1, B: 2, C: 3, "1": "A", "2": "B", "3": "C" }

TsEnumRecordValue::inc() returns Void for an opaque value, so every following auto-incremented member collapses.

This restores an existing code path rather than adding a rule: compute_rec already implements the constant folding from microsoft/TypeScript#50528 for literals, parens, unary and binary operators, template literals and enum member references. Const-variable references are the one production that was never wired in.

What qualifies as a constant

TypeScript decides this from the syntactic form alone, with no type resolution — confirmed as intentional and required for third-party transpilers in microsoft/TypeScript#63275, and described in evanw/esbuild#4387. A binding qualifies when it is a simple const with no type annotation, whose initializer is itself a constant expression, transitively — including ambient ones: declare const x = 1 folds, since ambient contexts only allow initializers that are literals. Members of declare enum with constant initializers fold the same way — with the usual auto-increment inside an ambient const enum, none in a plain ambient enum, and runtime reads of the ambient object left untouched, all matching tsc. Two rules fall out of that and both are covered by fixtures:

  • Type syntax removes constness. const foo = "s" as string, const foo: "s" = "s", foo satisfies T, <T>foo, foo! and as const all stay reverse-mapped, and so does enum E { A = foo as string } — an assertion on the reference is enough.
  • Within the same scope, a binding declared after the enum is not visible to it. The collection pass runs in source order, so a forward reference is simply not in the map yet — and it would be a TDZ error at runtime anyway. Across a function boundary tsc is more permissive; that case is listed under known limitations.

Verified against tsc 5.9.3

Every fixture case was diffed against transpileModule output. Folded: plain const, transitive chains, "a" + "b", template literals, numeric bindings, and const bindings in function or namespace scope. Not folded: type annotations and assertions, let, var, annotated or uninitialized declare const, ambient enum members without initializers, destructured bindings, and forward references.

Known limitations

  • Namespace member access (N.foo, N.M.foo) still produces a reverse mapping, and an auto-incremented member following it collapses to undefined. compute_member bails on any non-Ident object. Additive and independent; opened as TypeScript enum transform does not fold namespace member access (N.foo, N.M.foo) #12102.
  • An enum nested in a function-like body does not see outer const bindings declared later in the file. tsc folds those, since the body does not run at the declaration point: function f() { enum E { A = x, B } } const x = 1 gives A = 1, B = 2. Matching it needs deferred evaluation of nested enums, which is a different rule from the syntactic one implemented here. Left as-is — it only ever under-folds, which is the behavior on main today.
  • An ambient enum member referencing a const declared later in the file stays opaque — the collection pass runs in source order, while tsc folds it since ambient declarations are erased. Same deferred-evaluation family as the function-boundary case above.
  • Cross-file constants are not folded. tsc's own transpileModule does not fold them either — only createProgram does — so matching single-file semantics looks like the right contract here.
  • Where tsc needs the checker, this stays conservative. const foo: string = "s" makes tsc emit a string enum without folding the value; a syntactic transpiler cannot know the type, so the member stays reverse-mapped.

enum E { A = "s" as any } still diverges from tsc, which reverse-maps it. That behavior predates this PR — it was introduced by #11769 for #11761 — so it is out of scope here; reported as #12150. ts_enum_with_type_assertion is unchanged.

Tests: six fixtures under tests/fixture/, plus two cases added to ts_enum_is_mutable_true. Each fails without the change — verified by copying them into a worktree checked out at main. Full crate suite green (203 fixture tests, 5039 identity), plus swc --test tsc (4579), swc_ts_fast_strip (4458), swc --test projects (889, including the issues-11xxx/11761 fixture from #11769) and swc --test exec (451). One tsc conformance reference moved: in constEnum2.ts, g = CONST now resolves to a constant, so the member is inlined rather than emitted — the same treatment d = 10 already gets in that same const enum. The members that call Math.random() stay opaque and are still emitted. Also checked with RUSTFLAGS="--cfg swc_ast_unknown" since this touches a match over Expr.

Mutable enums

Under tsEnumIsMutable, enter_expr_for_inline_enum deliberately leaves reads of non-const enums as runtime reads. The collection pass mirrors that guard: const x = D.A only resolves when D is a const enum. This diverges from tsc, which folds it — but matching tsc here would contradict the option's own contract, and would emit const x = D.A while using the folded value for the enum member that reads it. Both sides are covered by ts_enum_is_mutable_true: a const from a mutable enum member stays a runtime read, one from a const enum member still folds.

Performance

The first version of the collection pass evaluated every const initializer in the program. compute takes the expression by value, so each one was cloned, and in real TypeScript most const initializers — arrow functions, object literals, calls — hit the Opaque catch-all immediately, so the clone was discarded. That showed up as a CodSpeed regression on the TypeScript benchmarks.

Checking the outermost form before cloning removes it: es/transform/baseline/common_typescript goes from 59.6 us back to 53.3 us, against 53.3 us on main. Measured with cargo bench on both. No test output changes.

BREAKING CHANGE: None.

Related issue (if exists):

`EnumValueComputer::compute_rec` gates its identifier arm on
`ctxt == unresolved_ctxt`, which only matches enum member references. A
reference to a real `const` binding fell through to `Opaque`, so members
initialized from one were emitted as reverse mappings instead of string
enum members, and numeric ones broke auto-increment: `inc()` returns
`Void` for an opaque value, so every following member collapsed to
`undefined`.

Resolve references to `const` bindings whose initializer is a constant
expression, following the syntactic rules TypeScript applies (see
microsoft/TypeScript#50528). A binding qualifies when it is a simple
non-ambient `const` with no type annotation and a constant initializer,
transitively. Type syntax removes constness, and a binding declared after
the enum is not visible to it.

Closes swc-project#11715
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7385335

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 2.39%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 199 untouched benchmarks
⏩ 61 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation es/lints/libs/three 50 ms 48.9 ms +2.39%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing baltasarblanco:fix/11715-ts-enum-const-var-folding (7385335) with main (cf7b5c9)

Open in CodSpeed

Footnotes

  1. 61 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c093ba8600

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated
Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs
Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs
`g = CONST` now resolves to a constant, so the member is inlined and no
longer emitted, matching how `d = 10` is already handled in the same
`const enum`. The remaining members stay opaque because they call
`Math.random()`.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0851e3e4fc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated
@baltasarblanco

Copy link
Copy Markdown
Contributor Author

Opened #12102 for the namespace member access limitation noted above.

… prior declarators

The const collection pass evaluated each initializer against an empty
`TsEnumRecord`, so `const x = E.A` could not resolve an already-recorded
enum member and was dropped from `const_vars`. It also visited every
declarator of a `VarDecl` before recording any of them, so a nested enum
in a later initializer could not see an earlier `const` from the same
declaration.

Both left the member opaque, and `inc()` returns `Void` for an opaque
value, so the following auto-incremented members collapsed to
`undefined`.

Evaluate against the accumulated enum record, and record each declarator
before traversing the next one. Order across separate statements is
unchanged: a `const` declared after the enum is still invisible to it.
…ding

Two cases raised in review, both of which fail on main:

- `enum E { A = "a" } const x = E.A; enum F { X = x }`, where the const
  initializer references an already-declared enum member.
- `const x = 1, y = (() => { enum E { A = x, B } })()`, where a nested
  enum references an earlier declarator of the same declaration.

Both were left opaque, so the following auto-incremented member
collapsed to `undefined`.
`compute` takes the initializer by value, so the collection pass cloned
every `const` initializer in the program, while most of them hit the
`Opaque` catch-all right away: in real TypeScript they are mostly arrow
functions, object literals and calls.

Check the outermost form before cloning. `es/transform/baseline/common_typescript`
goes from 59.6 us back to 53.3 us, against 53.3 us on main. No test
output changes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f6f9b27739

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs
Resolving const initializers against the enum record made
`const x = D.A; enum E { A = x }` fold to the compile-time value even with
`tsEnumIsMutable` enabled, where `enter_expr_for_inline_enum` deliberately
leaves reads of non-const enums as runtime reads. The emitted code
contradicted itself: `const x = D.A` was preserved while `E.A` used the
folded value.

Thread the option into the collection pass and mirror the guard from
`transform.rs`: when mutable enums are enabled, only members of `const enum`
declarations resolve through a member expression. Members of the enum being
evaluated are unaffected, and the default path (`ts_enum_is_mutable: false`)
is unchanged — no test output moves.
Extends the existing `ts_enum_is_mutable_true` case with both sides of the
guard: a const initialized from a mutable enum member, which must stay a
runtime read, and one from a `const enum` member, which still folds. Both
fail on main — the first because the value was folded away, the second
because it was not folded at all.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e8749f8a7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs
Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6130967389

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs
Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated
@baltasarblanco
Baltasar Blanco (baltasarblanco) force-pushed the fix/11715-ts-enum-const-var-folding branch from 6130967 to ca3679b Compare August 24, 2026 17:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca3679bf87

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated
@baltasarblanco
Baltasar Blanco (baltasarblanco) force-pushed the fix/11715-ts-enum-const-var-folding branch from ca3679b to 9976d67 Compare August 24, 2026 22:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9976d67ebe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/ts_enum.rs Outdated
@baltasarblanco
Baltasar Blanco (baltasarblanco) force-pushed the fix/11715-ts-enum-const-var-folding branch from 9976d67 to b7a2e5a Compare August 24, 2026 23:55

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7a2e5a9e2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated
Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs Outdated
Comment thread crates/swc_ecma_transforms_typescript/src/ts_enum.rs
Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs
`tsc` treats `declare const x = 1` and members of `declare enum` that have
constant initializers as constant enum expressions, while erasing the
declarations themselves. The collection pass skipped everything ambient, so
both stayed opaque and the following auto-incremented members collapsed to
`undefined`.

Ambient consts now populate `const_vars` like any other const. Ambient enum
members go to a separate `ambient_enum_record` that only the evaluator
consults: `tsc` folds them inside enum and const initializers but never
rewrites runtime reads of the ambient object, and the inliner keys on
`enum_record` membership. Member initializers evaluate against the normal
enum record with an ambient fallback, so an ambient member can read an
earlier concrete enum, a const binding, and bare sibling references keep
resolving. Reads reached through type syntax stay opaque: the ambient
lookup is gated on `allow_const_var`, which evaluation clears when it
crosses an assertion, and ambient member initializers themselves follow
the const-initializer rule, so type syntax inside them removes constness. The split also reproduces enum merging: the ambient half of a
merged enum folds in initializers while its runtime reads stay untouched.

In an ambient `const enum` every member is constant, so the usual
auto-increment applies, and its id is registered in `const_enum` so it stays
resolvable under `tsEnumIsMutable`. In a plain ambient enum a member without
an initializer stays opaque, matching `tsc`. The mutable-enum guard applies
to this pass as well: ambient members reading a mutable enum stay opaque.
@baltasarblanco
Baltasar Blanco (baltasarblanco) force-pushed the fix/11715-ts-enum-const-var-folding branch from b7a2e5a to 7bffbb1 Compare August 25, 2026 00:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7bffbb12f6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread crates/swc_ecma_transforms_typescript/src/semantic.rs

@kdy1 Donny/강동윤 (kdy1) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a test case

A plain ambient enum is a runtime object that can be reassigned like any
other non-const enum, but the ambient-record fallback resolved its members
unconditionally: under `tsEnumIsMutable`, an enum member reading one was
folded to the declared value while every other read of it stayed a runtime
read.

Gate the ambient fallback — in member lookup and in the sibling arm — on
the const-enum rule the option already applies elsewhere. The primary
record is untouched: how concrete enum reads behave under the option
predates this PR and is tracked in swc-project#12151.
@baltasarblanco

Copy link
Copy Markdown
Contributor Author

Thanks for adding the test — fixed in 7385335acb and it passes unchanged. The ambient fallback is now gated on the same const-enum rule the option applies elsewhere; concrete enum reads under the option are untouched, since that behavior predates this PR (#12151, which has a contributor working on it).

@kdy1 Donny/강동윤 (kdy1) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

This PR regressed the perf. Can you investigate?

@baltasarblanco

Copy link
Copy Markdown
Contributor Author

Investigated. Absolute walltime per commit, same machine and session (es/transform/baseline/common_typescript):

commit time
3c4f404 (PR base) 52.3 µs
f6f9b27 53.2 µs
8eeebc5 53.1 / 52.7 µs
7bffbb1 (ambient folding) 54.3 / 53.8 µs
7385335 (HEAD) 54.4 µs
e876e80 (main today) 51.6 µs

The regression is real, reproducible (two A/B pairs), ~+2%, and comes entirely from 7bffbb1. Part of the −2% CodSpeed shows is main having improved ~1.5% while this branch sits on the older base.

Profiled both sides (perf, dwarf call graphs): no single hot function — the benchmark input has no enum and no declare, so the ambient machinery never executes on it, and the delta is spread across allocation and the widened collection pass (±0.3pp each on malloc/clones/AST walk). I tried two targeted gates on the collection pass; one breaks the ambient fixtures, the other keeps all 205 tests green but doesn't recover the time — so this looks like the structural cost of the extra pass state rather than a fixable hot path.

My read: the ambient folding fixes real corruption (members collapsing to undefined, pinned in issue-11715-ambient, fails on main), which is worth 2% on this microbench — so unless you prefer otherwise, I'll leave it as is. If you'd rather keep the baseline flat, say the word and I'll move the ambient support to a follow-up: a revert commit on top (no history rewrite — your mutable-ambient test pins preservation and keeps passing either way) and reopen it once the cost is understood.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

TypeScript enum transform does not treat const string variables as compile-time constants

2 participants