fix(es/typescript): Treat const variable references as enum constants - #12101
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: 2e8749f 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 degrade performance by 2.04%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | es/transform/baseline/common_typescript |
354.4 µs | 361.8 µs | -2.04% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing baltasarblanco:fix/11715-ts-enum-const-var-folding (2e8749f) with main (3c4f404)
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".
| let empty = TsEnumRecord::default(); | ||
| let value = EnumValueComputer { | ||
| enum_id: &id.to_id(), | ||
| unresolved_ctxt: self.unresolved_ctxt, | ||
| record: &empty, |
There was a problem hiding this comment.
Resolve enum members in const initializers
When a const initializer contains an enum member reference, e.g. enum E { A = "a" } const x = E.A; enum F { X = x }, TypeScript treats x as a constant enum expression and emits F["X"] = "a" with no reverse mapping. This code evaluates const initializers against an empty TsEnumRecord, so compute_member cannot resolve already-seen enum members; x is omitted from const_vars and any later enum member using it is emitted as an opaque computed member, adding the wrong reverse mapping. Use the accumulated enum record for const-initializer evaluation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d547a9a. The collection pass now evaluates const initializers against the accumulated enum record instead of an empty one, so const x = E.A resolves. Verified against tsc 5.9.3; fixture issue-11715-enum-member-init, which fails on main.
| .compute(init.clone(), EvalCtx::CONST_INIT); | ||
|
|
||
| if value.is_const() { | ||
| self.info.const_vars.insert(id.to_id(), value); |
There was a problem hiding this comment.
Track namespace-qualified const references
When the const is exported from a namespace, e.g. namespace N { export const n = 1 } enum E { A = N.n, B }, TypeScript treats N.n as a constant enum expression and auto-increments B to 2. This only stores the local binding id n, while enum evaluation of N.n remains an opaque member expression, so A is not treated as constant and the following defaulted member falls back to undefined; include the namespace/exported binding in the const lookup or resolve qualified const member expressions.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed against tsc 5.9.3, including the following member collapsing to undefined. Out of scope here: compute_member bails on any non-Ident object, which is an independent change. Opened as #12102.
| &id.to_id(), | ||
| &default_init, | ||
| &self.info.enum_record, | ||
| &self.info.const_vars, |
There was a problem hiding this comment.
Account for later outer consts in nested enums
When an enum is inside a function-like body that appears before an outer const declaration, e.g. function f(){ enum E { A = x, B } } const x = 1, TypeScript still treats x as a constant enum expression and emits A = 1 / B = 2. This pass evaluates the enum immediately with the current const_vars map, so the later outer const has not been recorded yet and the nested enum is emitted as a non-constant member with B becoming undefined; defer or precollect eligible outer-scope const initializers without enabling same-scope forward references.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed against tsc 5.9.3. Matching it needs deferred evaluation of enums nested in function bodies, which is a different rule from the syntactic one this PR implements — top-level forward references must keep their current behavior, since tsc does not fold those either and they are a TDZ error at runtime. Left as-is; it only ever under-folds, which is what main does today. Noted under known limitations.
`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".
| } | ||
|
|
||
| fn visit_var_decl(&mut self, node: &VarDecl) { | ||
| node.visit_children_with(self); |
There was a problem hiding this comment.
Record earlier declarators before visiting nested enums
For a multi-declarator const like const x = 1, y = (() => { enum E { A = x, B } })();, TypeScript folds the nested enum to A = 1 and auto-increments B = 2. This visitor walks y's initializer before inserting the earlier x declarator into const_vars, so the nested enum is recorded as opaque and emits A = x with B = undefined; collect eligible preceding declarators before traversing later initializer bodies.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in d547a9a. Each declarator is recorded before the next one is traversed. Verified against tsc 5.9.3; fixture issue-11715-declarator-order, which fails on main.
|
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".
| let value = EnumValueComputer { | ||
| enum_id: &id.to_id(), | ||
| unresolved_ctxt: self.unresolved_ctxt, | ||
| record: &self.info.enum_record, |
There was a problem hiding this comment.
Respect mutable enum reads in const folding
When tsEnumIsMutable is enabled, enter_expr_for_inline_enum deliberately leaves reads from non-const enums un-inlined, but this new const-variable collection still resolves const x = D.A through enum_record here. In code such as enum D { A = 1 } D.A = 5; const x = D.A; enum E { A = x }, mutable-enum mode keeps x as a runtime read of D.A while the semantic map records x as 1 and emits E.A = 1, changing runtime behavior; skip recording const initializers that depend on non-const enum members when mutable enums are enabled, or thread that option into the evaluator.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — this was a real regression, fixed in 8eeebc5. Confirmed by diffing against main: enum D { A = 1 } (D as any).A = 5; const x = D.A; enum E { A = x } emitted E["A"] = 1 on this branch versus E[E["A"] = x] = "A" on main. The emitted code contradicted itself — const x = D.A was preserved while the enum member used the folded value.
The option is now threaded into the collection pass and mirrors the guard in transform.rs: with mutable enums enabled, only const enum members resolve through a member expression. Both sides are covered in ts_enum_is_mutable_true.
Separately, enum F { A = D.A } (no const in between) still folds under tsEnumIsMutable — that path goes through transform_ts_enum_member, is unchanged by this PR and behaves the same on main. I'll report it separately.
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".
| let value = EnumValueComputer { | ||
| enum_id: &id.to_id(), | ||
| unresolved_ctxt: self.unresolved_ctxt, | ||
| record: &self.info.enum_record, |
There was a problem hiding this comment.
Include ambient enum members in const folding
When a const initializer reads an ambient enum member, e.g. declare enum A { X = 1 } const x = A.X; enum B { Y = x, Z }, TypeScript treats x as a constant enum expression and emits Y = 1 / Z = 2. This new const-variable collection evaluates against self.info.enum_record, but declare enums are skipped from that record, so x is never inserted into const_vars and B is emitted as a runtime read with the following member becoming undefined; consider recording ambient enum values for semantic folding even though their declarations are removed.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| fn visit_var_decl(&mut self, node: &VarDecl) { | ||
| let track = !self.skip_transform_info && !node.declare && node.kind == VarDeclKind::Const; |
There was a problem hiding this comment.
Record ambient const initializers
For ambient literal const declarations such as declare const x = 1; enum E { A = x, B }, TypeScript erases the declaration but still classifies x as a constant enum expression and emits A = 1 / B = 2. Because track rejects every declare var decl here, the declaration is stripped while x is never added to const_vars, so the enum is emitted as a runtime read of a non-emitted binding and the following member becomes undefined; allow ambient declare const declarations with constant initializers to populate the semantic map without retaining them.
Useful? React with 👍 / 👎.
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, non-ambient
const, with no type annotation, whose initializer is itself a constant expression, transitively. 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,declare const, 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.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 and I'll report it separately.ts_enum_with_type_assertionis unchanged.Tests: five 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):