Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/fix-ts-enum-const-var-folding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
swc_core: patch
swc_ecma_transforms_typescript: patch
---
fix(es/typescript): Treat const variable references as enum constants
1 change: 0 additions & 1 deletion crates/swc/tests/tsc-references/constEnum2.1.normal.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,5 @@ var CONST = 9000 % 2;
var D = function(D) {
D[D["e"] = 199 * Math.floor(Math.random() * 1000)] = "e";
D[D["f"] = 10 - 100 * Math.floor(Math.random() % 8)] = "f";
D[D["g"] = CONST] = "g";
return D;
}(D || {});
2 changes: 1 addition & 1 deletion crates/swc/tests/tsc-references/constEnum2.2.minified.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
//// [constEnum2.ts]
var D, D1 = ((D = D1 || {})[D.e = 199 * Math.floor(1000 * Math.random())] = "e", D[D.f = 10 - 100 * Math.floor(Math.random() % 8)] = "f", D[D.g = 0] = "g", D);
var D, D1 = ((D = D1 || {})[D.e = 199 * Math.floor(1000 * Math.random())] = "e", D[D.f = 10 - 100 * Math.floor(Math.random() % 8)] = "f", D);
48 changes: 46 additions & 2 deletions crates/swc_ecma_transforms_typescript/src/semantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use swc_ecma_visit::{noop_visit_type, Visit, VisitWith};
use crate::{
retain::{should_retain_decl, IsConcrete},
shared::{enum_member_name, get_module_ident},
ts_enum::{EnumValueComputer, TsEnumRecord, TsEnumRecordKey, TsEnumRecordValue},
ts_enum::{EnumValueComputer, EvalCtx, TsEnumRecord, TsEnumRecordKey, TsEnumRecordValue},
};

#[derive(Debug, Default)]
Expand All @@ -19,6 +19,7 @@ pub(crate) struct SemanticInfo {
pub enum_record: TsEnumRecord,
pub const_enum: FxHashSet<Id>,
pub namespace_import_equals_usage: FxHashSet<Span>,
pub const_vars: FxHashMap<Id, TsEnumRecordValue>,
}

impl SemanticInfo {
Expand Down Expand Up @@ -48,6 +49,7 @@ pub(crate) fn analyze_program(
unresolved_mark: Mark,
seed_usage: FxHashSet<Id>,
flow_syntax: bool,
ts_enum_is_mutable: bool,
) -> SemanticInfo {
let mut analyzer = SemanticAnalyzer {
unresolved_ctxt: SyntaxContext::empty().apply_mark(unresolved_mark),
Expand All @@ -60,6 +62,7 @@ pub(crate) fn analyze_program(
namespace_id: None,
skip_transform_info: false,
flow_syntax,
ts_enum_is_mutable,
};

program.visit_with(&mut analyzer);
Expand All @@ -75,6 +78,7 @@ struct SemanticAnalyzer {
namespace_id: Option<Id>,
skip_transform_info: bool,
flow_syntax: bool,
ts_enum_is_mutable: bool,
}

#[derive(Default)]
Expand Down Expand Up @@ -257,6 +261,7 @@ impl SemanticAnalyzer {
enum_id: &Id,
default_init: &TsEnumRecordValue,
record: &TsEnumRecord,
const_vars: &FxHashMap<Id, TsEnumRecordValue>,
unresolved_ctxt: SyntaxContext,
flow_syntax: bool,
) -> TsEnumRecordValue {
Expand All @@ -267,8 +272,10 @@ impl SemanticAnalyzer {
enum_id,
unresolved_ctxt,
record,
const_vars,
const_enum_only: None,
}
.compute(expr)
.compute(expr, EvalCtx::MEMBER)
})
.filter(TsEnumRecordValue::has_value)
.unwrap_or_else(|| {
Expand Down Expand Up @@ -536,6 +543,40 @@ impl Visit for SemanticAnalyzer {
}
}

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


for decl in &node.decls {
decl.visit_with(self);

if !track {
continue;
}

let Pat::Ident(BindingIdent { id, type_ann: None }) = &decl.name else {
continue;
};
let Some(init) = &decl.init else { continue };

if !EnumValueComputer::can_fold_shape(init) {
continue;
}

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.

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

const_vars: &self.info.const_vars,
const_enum_only: self.ts_enum_is_mutable.then_some(&self.info.const_enum),
}
.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.

}
}
}

fn visit_ts_enum_decl(&mut self, node: &TsEnumDecl) {
node.visit_children_with(self);

Expand Down Expand Up @@ -566,6 +607,7 @@ impl Visit for SemanticAnalyzer {
&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.

self.unresolved_ctxt,
self.flow_syntax,
);
Expand Down Expand Up @@ -655,6 +697,7 @@ mod tests {
&id("E"),
&TsEnumRecordValue::Void,
&Default::default(),
&Default::default(),
SyntaxContext::empty(),
true,
);
Expand All @@ -672,6 +715,7 @@ mod tests {
&id("E"),
&TsEnumRecordValue::from(2.0),
&Default::default(),
&Default::default(),
SyntaxContext::empty(),
false,
);
Expand Down
8 changes: 6 additions & 2 deletions crates/swc_ecma_transforms_typescript/src/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ use crate::{
retain::{should_retain_module_item, should_retain_stmt},
semantic::SemanticInfo,
shared::enum_member_name,
ts_enum::{static_enum_member_name, EnumValueComputer, TsEnumRecordKey, TsEnumRecordValue},
ts_enum::{
static_enum_member_name, EnumValueComputer, EvalCtx, TsEnumRecordKey, TsEnumRecordValue,
},
utils::{assign_value_to_this_private_prop, assign_value_to_this_prop, Factory},
};

Expand Down Expand Up @@ -1112,6 +1114,8 @@ impl Transform {
enum_id: &id.to_id(),
unresolved_ctxt: self.unresolved_ctxt,
record: &self.semantic.enum_record,
const_vars: &self.semantic.const_vars,
const_enum_only: None,
};

let member_list: Vec<_> = members
Expand All @@ -1133,7 +1137,7 @@ impl Transform {
// references can be rewritten to runtime property
// accesses. Implicit Flow enum members do not have an
// initializer, so keep the semantic value as-is.
let mut recomputed = enum_computer.compute(init);
let mut recomputed = enum_computer.compute(init, EvalCtx::RECOMPUTE);
if let TsEnumRecordValue::Opaque(expr) = &mut recomputed {
expr.visit_mut_with(&mut RefRewriter {
query: EnumMemberRefQuery {
Expand Down
Loading
Loading