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);
38 changes: 36 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 @@ -257,6 +258,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 +269,9 @@ impl SemanticAnalyzer {
enum_id,
unresolved_ctxt,
record,
const_vars,
}
.compute(expr)
.compute(expr, EvalCtx::MEMBER)
})
.filter(TsEnumRecordValue::has_value)
.unwrap_or_else(|| {
Expand Down Expand Up @@ -536,6 +539,34 @@ impl Visit for SemanticAnalyzer {
}
}

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.


if self.skip_transform_info || node.declare || node.kind != VarDeclKind::Const {
return;
}

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

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.

const_vars: &self.info.const_vars,
}
.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 +597,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 +687,7 @@ mod tests {
&id("E"),
&TsEnumRecordValue::Void,
&Default::default(),
&Default::default(),
SyntaxContext::empty(),
true,
);
Expand All @@ -672,6 +705,7 @@ mod tests {
&id("E"),
&TsEnumRecordValue::from(2.0),
&Default::default(),
&Default::default(),
SyntaxContext::empty(),
false,
);
Expand Down
7 changes: 5 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,7 @@ impl Transform {
enum_id: &id.to_id(),
unresolved_ctxt: self.unresolved_ctxt,
record: &self.semantic.enum_record,
const_vars: &self.semantic.const_vars,
};

let member_list: Vec<_> = members
Expand All @@ -1133,7 +1136,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
106 changes: 84 additions & 22 deletions crates/swc_ecma_transforms_typescript/src/ts_enum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub(crate) struct EnumValueComputer<'a> {
pub enum_id: &'a Id,
pub unresolved_ctxt: SyntaxContext,
pub record: &'a TsEnumRecord,
pub const_vars: &'a FxHashMap<Id, TsEnumRecordValue>,
}

/// Returns a statically known enum member key without discarding lone
Expand All @@ -117,13 +118,52 @@ pub(crate) fn static_enum_member_name(property: &MemberProp) -> Option<Wtf8Atom>
}
}

/// Evaluation context for [`EnumValueComputer::compute_rec`].
///
/// TypeScript decides enum member constness from the *syntactic* form of the
/// initializer, without type resolution. See
/// https://github.com/microsoft/TypeScript/pull/50528 and the constraints
/// described in https://github.com/evanw/esbuild/issues/4387.
#[derive(Clone, Copy)]
pub(crate) struct EvalCtx {
/// Whether a reference to a `const` binding may be resolved to its value.
/// Disabled under type syntax: `enum E { A = foo as string }` is not a
/// constant for TypeScript even when `foo` is.
allow_const_var: bool,
/// Whether type syntax makes the whole expression non-constant instead of
/// being stripped. Used when evaluating a `const` initializer, where any
/// annotation or assertion removes constness.
ts_is_opaque: bool,
}

impl EvalCtx {
/// Context for a `const` variable initializer.
pub(crate) const CONST_INIT: Self = Self {
allow_const_var: true,
ts_is_opaque: true,
};
/// Context for an enum member initializer.
pub(crate) const MEMBER: Self = Self {
allow_const_var: true,
ts_is_opaque: false,
};
/// Context for re-computing a member whose value the pre-pass already
/// classified as non-constant. Resolving `const` bindings here would
/// override that verdict: the binding map is complete by this point, and
/// type assertions have already been stripped from the initializer.
pub(crate) const RECOMPUTE: Self = Self {
allow_const_var: false,
ts_is_opaque: false,
};
}

/// https://github.com/microsoft/TypeScript/pull/50528
impl EnumValueComputer<'_> {
pub fn compute(&self, expr: Box<Expr>) -> TsEnumRecordValue {
self.compute_rec(expr)
pub fn compute(&self, expr: Box<Expr>, ctx: EvalCtx) -> TsEnumRecordValue {
self.compute_rec(expr, ctx)
}

fn compute_rec(&self, expr: Box<Expr>) -> TsEnumRecordValue {
fn compute_rec(&self, expr: Box<Expr>, ctx: EvalCtx) -> TsEnumRecordValue {
match *expr {
Expr::Lit(Lit::Str(s)) => TsEnumRecordValue::String(s.value),
Expr::Lit(Lit::Num(n)) => TsEnumRecordValue::Number(n),
Expand Down Expand Up @@ -155,29 +195,51 @@ impl EnumValueComputer<'_> {
}
}
}
Expr::Paren(e) => self.compute_rec(e.expr),
Expr::Unary(e) => self.compute_unary(e),
Expr::Bin(e) => self.compute_bin(e),
Expr::Member(e) => self.compute_member(e),
Expr::Tpl(e) => self.compute_tpl(e),
// A reference to a `const` binding whose initializer is a constant
// expression. TypeScript treats it as a compile-time constant, so
// the member is emitted as a string enum instead of a reverse
// mapping. Only reachable when not under type syntax.
Expr::Ident(ref ident) if ctx.allow_const_var => self
.const_vars
.get(&ident.to_id())
.cloned()
.unwrap_or_else(|| TsEnumRecordValue::Opaque(expr)),
Expr::Paren(e) => self.compute_rec(e.expr, ctx),
Expr::Unary(e) => self.compute_unary(e, ctx),
Expr::Bin(e) => self.compute_bin(e, ctx),
Expr::Member(e) => self.compute_member(e, ctx),
Expr::Tpl(e) => self.compute_tpl(e, ctx),
// Handle TypeScript type expressions by stripping them
// and computing the inner expression
Expr::TsAs(TsAsExpr { expr, .. })
| Expr::TsNonNull(TsNonNullExpr { expr, .. })
| Expr::TsTypeAssertion(TsTypeAssertion { expr, .. })
| Expr::TsConstAssertion(TsConstAssertion { expr, .. })
| Expr::TsInstantiation(TsInstantiation { expr, .. })
| Expr::TsSatisfies(TsSatisfiesExpr { expr, .. }) => self.compute_rec(expr),
Expr::TsAs(TsAsExpr { expr: inner, .. })
| Expr::TsNonNull(TsNonNullExpr { expr: inner, .. })
| Expr::TsTypeAssertion(TsTypeAssertion { expr: inner, .. })
| Expr::TsConstAssertion(TsConstAssertion { expr: inner, .. })
| Expr::TsInstantiation(TsInstantiation { expr: inner, .. })
| Expr::TsSatisfies(TsSatisfiesExpr { expr: inner, .. }) => {
if ctx.ts_is_opaque {
// Any annotation or assertion removes constness from a
// `const` initializer.
return TsEnumRecordValue::Opaque(inner);
}
self.compute_rec(
inner,
EvalCtx {
allow_const_var: false,
..ctx
},
)
}
_ => TsEnumRecordValue::Opaque(expr),
}
}

fn compute_unary(&self, expr: UnaryExpr) -> TsEnumRecordValue {
fn compute_unary(&self, expr: UnaryExpr, ctx: EvalCtx) -> TsEnumRecordValue {
if !matches!(expr.op, op!(unary, "+") | op!(unary, "-") | op!("~")) {
return TsEnumRecordValue::Opaque(expr.into());
}

let inner = self.compute_rec(expr.arg);
let inner = self.compute_rec(expr.arg, ctx);

let TsEnumRecordValue::Number(num) = inner else {
return TsEnumRecordValue::Opaque(
Expand All @@ -198,7 +260,7 @@ impl EnumValueComputer<'_> {
}
}

fn compute_bin(&self, expr: BinExpr) -> TsEnumRecordValue {
fn compute_bin(&self, expr: BinExpr, ctx: EvalCtx) -> TsEnumRecordValue {
let origin_expr = expr.clone();
if !matches!(
expr.op,
Expand All @@ -218,8 +280,8 @@ impl EnumValueComputer<'_> {
return TsEnumRecordValue::Opaque(origin_expr.into());
}

let left = self.compute_rec(expr.left);
let right = self.compute_rec(expr.right);
let left = self.compute_rec(expr.left, ctx);
let right = self.compute_rec(expr.right, ctx);

if expr.op == BinaryOp::Add && (left.is_string() || right.is_string()) {
let mut value = Wtf8Buf::new();
Expand Down Expand Up @@ -267,7 +329,7 @@ impl EnumValueComputer<'_> {
}
}

fn compute_member(&self, expr: MemberExpr) -> TsEnumRecordValue {
fn compute_member(&self, expr: MemberExpr, _ctx: EvalCtx) -> TsEnumRecordValue {
let opaque_expr = TsEnumRecordValue::Opaque(expr.clone().into());

let Some(member_name) = static_enum_member_name(&expr.prop) else {
Expand All @@ -288,7 +350,7 @@ impl EnumValueComputer<'_> {
.unwrap_or(opaque_expr)
}

fn compute_tpl(&self, expr: Tpl) -> TsEnumRecordValue {
fn compute_tpl(&self, expr: Tpl, ctx: EvalCtx) -> TsEnumRecordValue {
let opaque_expr = TsEnumRecordValue::Opaque(expr.clone().into());

let Tpl { exprs, quasis, .. } = expr;
Expand All @@ -304,7 +366,7 @@ impl EnumValueComputer<'_> {
let mut string = Wtf8Buf::from(first_cooked);

for (q, expr) in quasis_iter.zip(exprs) {
let expr = self.compute_rec(expr);
let expr = self.compute_rec(expr, ctx);

if !expr.push_to_string(&mut string) {
return opaque_expr;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const s = "aThisIs";
const n = 1;
const concat = "a" + "b";
const chain = s;

enum StringEnum {
A = s,
B = "bThisIs",
}

enum NumericAutoIncrement {
A = n,
B,
C,
}

enum ConstantExpr {
A = concat,
B = chain,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const s = "aThisIs";
const n = 1;
const concat = "a" + "b";
const chain = s;
var StringEnum = /*#__PURE__*/ function(StringEnum) {
StringEnum["A"] = "aThisIs";
StringEnum["B"] = "bThisIs";
return StringEnum;
}(StringEnum || {});
var NumericAutoIncrement = /*#__PURE__*/ function(NumericAutoIncrement) {
NumericAutoIncrement[NumericAutoIncrement["A"] = 1] = "A";
NumericAutoIncrement[NumericAutoIncrement["B"] = 2] = "B";
NumericAutoIncrement[NumericAutoIncrement["C"] = 3] = "C";
return NumericAutoIncrement;
}(NumericAutoIncrement || {});
var ConstantExpr = /*#__PURE__*/ function(ConstantExpr) {
ConstantExpr["A"] = "ab";
ConstantExpr["B"] = "aThisIs";
return ConstantExpr;
}(ConstantExpr || {});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const annotated: string = "s";
const asserted = "s" as string;
const constAsserted = "s" as const;
let mutable = "s";
var hoisted = "s";
const { destructured } = { destructured: "s" };
const poisoned = "s";

enum TypeAnnotation {
A = annotated,
B = "b",
}

enum TypeAssertion {
A = asserted,
B = constAsserted,
}

enum NotConstBinding {
A = mutable,
B = hoisted,
C = destructured,
}

enum AssertionOnReference {
A = poisoned as string,
B = "b",
}

enum ForwardReference {
A = declaredLater,
B = "b",
}

const declaredLater = "s";
Loading
Loading