Skip to content

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

Open
Baltasar Blanco (baltasarblanco) wants to merge 8 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 8 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, 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:

  • 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, declare const, 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.
  • 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 and I'll report it separately. ts_enum_with_type_assertion is unchanged.

Tests: five 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: 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

@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 2.04%

⚠️ 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 regressed benchmark
✅ 199 untouched benchmarks
⏩ 61 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

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 on lines +555 to +559
let empty = TsEnumRecord::default();
let value = EnumValueComputer {
enum_id: &id.to_id(),
unresolved_ctxt: self.unresolved_ctxt,
record: &empty,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

@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".

}

fn visit_var_decl(&mut self, node: &VarDecl) {
node.visit_children_with(self);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@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".

let value = EnumValueComputer {
enum_id: &id.to_id(),
unresolved_ctxt: self.unresolved_ctxt,
record: &self.info.enum_record,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@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".

let value = EnumValueComputer {
enum_id: &id.to_id(),
unresolved_ctxt: self.unresolved_ctxt,
record: &self.info.enum_record,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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

1 participant