fix(es/typescript): Treat const variable references as enum constants - #12101
fix(es/typescript): Treat const variable references as enum constants#12101Baltasar Blanco (baltasarblanco) wants to merge 11 commits into
Conversation
`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 detectedLatest 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 |
Merging this PR will improve performance by 2.39%
|
| 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)
Footnotes
-
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. ↩
There was a problem hiding this comment.
💡 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".
`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()`.
There was a problem hiding this comment.
💡 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".
|
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.
There was a problem hiding this comment.
💡 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".
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.
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
6130967 to
ca3679b
Compare
There was a problem hiding this comment.
💡 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".
ca3679b to
9976d67
Compare
There was a problem hiding this comment.
💡 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".
9976d67 to
b7a2e5a
Compare
There was a problem hiding this comment.
💡 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".
`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.
b7a2e5a to
7bffbb1
Compare
There was a problem hiding this comment.
💡 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".
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.
|
Thanks for adding the test — fixed in |
|
Investigated. Absolute walltime per commit, same machine and session (
The regression is real, reproducible (two A/B pairs), ~+2%, and comes entirely from Profiled both sides ( My read: the ambient folding fixes real corruption (members collapsing to |

Description:
EnumValueComputer::compute_recgates its identifier arm onctxt == unresolved_ctxt, which only matches references to sibling enum members. A reference to a realconstbinding is a resolved binding, so it fell through to theOpaquecatch-all.Two consequences. The reported one is emit shape:
The second is not in the issue and is worse — numeric members silently become
undefined:TsEnumRecordValue::inc()returnsVoidfor an opaque value, so every following auto-incremented member collapses.This restores an existing code path rather than adding a rule:
compute_recalready 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
constwith no type annotation, whose initializer is itself a constant expression, transitively — including ambient ones:declare const x = 1folds, since ambient contexts only allow initializers that are literals. Members ofdeclare enumwith constant initializers fold the same way — with the usual auto-increment inside an ambientconst enum, none in a plain ambient enum, and runtime reads of the ambient object left untouched, all matchingtsc. Two rules fall out of that and both are covered by fixtures:const foo = "s" as string,const foo: "s" = "s",foo satisfies T,<T>foo,foo!andas constall stay reverse-mapped, and so doesenum E { A = foo as string }— an assertion on the reference is enough.tscis more permissive; that case is listed under known limitations.Verified against
tsc5.9.3Every fixture case was diffed against
transpileModuleoutput. Folded: plainconst, transitive chains,"a" + "b", template literals, numeric bindings, andconstbindings in function or namespace scope. Not folded: type annotations and assertions,let,var, annotated or uninitializeddeclare const, ambient enum members without initializers, destructured bindings, and forward references.Known limitations
N.foo,N.M.foo) still produces a reverse mapping, and an auto-incremented member following it collapses toundefined.compute_memberbails on any non-Identobject. Additive and independent; opened as TypeScript enum transform does not fold namespace member access (N.foo,N.M.foo) #12102.constbindings declared later in the file.tscfolds those, since the body does not run at the declaration point:function f() { enum E { A = x, B } } const x = 1givesA = 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 onmaintoday.constdeclared later in the file stays opaque — the collection pass runs in source order, whiletscfolds it since ambient declarations are erased. Same deferred-evaluation family as the function-boundary case above.tsc's owntranspileModuledoes not fold them either — onlycreateProgramdoes — so matching single-file semantics looks like the right contract here.tscneeds the checker, this stays conservative.const foo: string = "s"makestscemit 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 fromtsc, 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_assertionis unchanged.Tests: six fixtures under
tests/fixture/, plus two cases added tots_enum_is_mutable_true. Each fails without the change — verified by copying them into a worktree checked out atmain. Full crate suite green (203 fixture tests, 5039 identity), plusswc --test tsc(4579),swc_ts_fast_strip(4458),swc --test projects(889, including theissues-11xxx/11761fixture from #11769) andswc --test exec(451). One tsc conformance reference moved: inconstEnum2.ts,g = CONSTnow resolves to a constant, so the member is inlined rather than emitted — the same treatmentd = 10already gets in that sameconst enum. The members that callMath.random()stay opaque and are still emitted. Also checked withRUSTFLAGS="--cfg swc_ast_unknown"since this touches amatchoverExpr.Mutable enums
Under
tsEnumIsMutable,enter_expr_for_inline_enumdeliberately leaves reads of non-const enums as runtime reads. The collection pass mirrors that guard:const x = D.Aonly resolves whenDis aconst enum. This diverges fromtsc, which folds it — but matchingtschere would contradict the option's own contract, and would emitconst x = D.Awhile using the folded value for the enum member that reads it. Both sides are covered byts_enum_is_mutable_true: a const from a mutable enum member stays a runtime read, one from aconst enummember still folds.Performance
The first version of the collection pass evaluated every
constinitializer in the program.computetakes the expression by value, so each one was cloned, and in real TypeScript mostconstinitializers — arrow functions, object literals, calls — hit theOpaquecatch-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_typescriptgoes from 59.6 us back to 53.3 us, against 53.3 us onmain. Measured withcargo benchon both. No test output changes.BREAKING CHANGE: None.
Related issue (if exists):