Describe the bug
When an enum member is initialized from a constant exported by a namespace, swc emits a
reverse mapping instead of folding the value. tsc folds it and emits a plain string enum.
For declare namespace, the consequence goes beyond a shape difference: the namespace is
erased from the output, but the member access survives in the initializer, so the emitted
module throws a ReferenceError when that enum is initialized.
This is not a checker-dependent difference: tsc's own transpileModule — single file, no
type resolution, the same constraint swc operates under — already folds the one-level form,
the arbitrarily nested form, and the ambient form.
The result is also inconsistent inside a single enum: in enum E { A = N.foo, B = "b" },
B is emitted as a string enum member while A is reverse-mapped, so the object ends up
with a spurious E["s"] = "A" property.
This is the follow-up to the limitation noted in #12101, which fixes the plain const
variable case (#11715) but leaves member access untouched.
Input code
namespace N { export const foo = "s"; }
enum E { A = N.foo, B = "b" }
namespace M { export namespace Inner { export const bar = "t"; } }
enum F { A = M.Inner.bar, B = "b" }
declare namespace D { enum A { X = 1 } }
enum G { P = D.A.X, Q }
Config
{
"jsc": {
"parser": { "syntax": "typescript", "tsx": false },
"target": "es2022",
"loose": false,
"minify": { "compress": false, "mangle": false }
},
"module": { "type": "es6" },
"minify": false,
"isModule": true
}
Expected behavior
tsc 5.9.3, transpileModule, target: ES2022, module: ESNext (namespace emit elided):
var E;
(function (E) {
E["A"] = "s";
E["B"] = "b";
})(E || (E = {}));
var F;
(function (F) {
F["A"] = "t";
F["B"] = "b";
})(F || (F = {}));
var G;
(function (G) {
G[G["P"] = 1] = "P";
G[G["Q"] = 2] = "Q";
})(G || (G = {}));
Evaluating that output and reading the three objects:
{"E":{"A":"s","B":"b"},"F":{"A":"t","B":"b"},"G":{"1":"P","2":"Q","P":1,"Q":2}}
Actual behavior
swc 1.16.1 (namespace emit elided):
var E = function(E) {
E[E["A"] = N.foo] = "A";
E["B"] = "b";
return E;
}(E || {});
var F = function(F) {
F[F["A"] = M.Inner.bar] = "A";
F["B"] = "b";
return F;
}(F || {});
var G = function(G) {
G[G["P"] = D.A.X] = "P";
G[G["Q"] = void 0] = "Q";
return G;
}(G || {});
var N, M;
Evaluating that output:
ReferenceError: D is not defined
G shows two distinct failures, both downstream of the same gap:
D is a declare namespace, so it is erased from the emit — note that var N, M; does
not declare it — while D.A.X survives inside the initializer. Initializing G throws.
Q is emitted as void 0: an opaque initializer leaves the auto-increment with no value
to continue from, so Q would be undefined rather than 2 even if D existed.
Version
1.16.1
Additional context
Root cause — crates/swc_ecma_transforms_typescript/src/ts_enum.rs, compute_member.
Two independent gaps:
-
The object guard bails on anything that is not a bare Ident:
let Expr::Ident(ident) = *expr.obj else {
return opaque_expr;
};
so M.Inner.bar and D.A.X — whose object is another MemberExpr — can never fold,
regardless of anything else.
-
Even for the one-level N.foo, the lookup goes against TsEnumRecord, keyed by
(enum_id, member_name) and populated only with enum members. Namespace constants never
land there, so there is no value to fold to.
Lifting the guard alone would not fix the nested case; a source of namespace constant values
is needed as well. Note that #12101 inserts a const_enum_only check between the object
guard and the record lookup, and adds a separate ambient record consulted as a fallback, so
any namespace value source would have to sit behind the same gating rather than beside it.
References
I'm happy to send a PR for this once #12101 is resolved, since both touch the same function.
Describe the bug
When an enum member is initialized from a constant exported by a
namespace, swc emits areverse mapping instead of folding the value.
tscfolds it and emits a plain string enum.For
declare namespace, the consequence goes beyond a shape difference: the namespace iserased from the output, but the member access survives in the initializer, so the emitted
module throws a
ReferenceErrorwhen that enum is initialized.This is not a checker-dependent difference:
tsc's owntranspileModule— single file, notype resolution, the same constraint swc operates under — already folds the one-level form,
the arbitrarily nested form, and the ambient form.
The result is also inconsistent inside a single enum: in
enum E { A = N.foo, B = "b" },Bis emitted as a string enum member whileAis reverse-mapped, so the object ends upwith a spurious
E["s"] = "A"property.This is the follow-up to the limitation noted in #12101, which fixes the plain
constvariable case (#11715) but leaves member access untouched.
Input code
Config
{ "jsc": { "parser": { "syntax": "typescript", "tsx": false }, "target": "es2022", "loose": false, "minify": { "compress": false, "mangle": false } }, "module": { "type": "es6" }, "minify": false, "isModule": true }Expected behavior
tsc5.9.3,transpileModule,target: ES2022,module: ESNext(namespace emit elided):Evaluating that output and reading the three objects:
Actual behavior
swc 1.16.1 (namespace emit elided):
Evaluating that output:
Gshows two distinct failures, both downstream of the same gap:Dis adeclare namespace, so it is erased from the emit — note thatvar N, M;doesnot declare it — while
D.A.Xsurvives inside the initializer. InitializingGthrows.Qis emitted asvoid 0: an opaque initializer leaves the auto-increment with no valueto continue from, so
Qwould beundefinedrather than2even ifDexisted.Version
1.16.1
Additional context
Root cause —
crates/swc_ecma_transforms_typescript/src/ts_enum.rs,compute_member.Two independent gaps:
The object guard bails on anything that is not a bare
Ident:so
M.Inner.barandD.A.X— whose object is anotherMemberExpr— can never fold,regardless of anything else.
Even for the one-level
N.foo, the lookup goes againstTsEnumRecord, keyed by(enum_id, member_name)and populated only with enum members. Namespace constants neverland there, so there is no value to fold to.
Lifting the guard alone would not fix the nested case; a source of namespace constant values
is needed as well. Note that #12101 inserts a
const_enum_onlycheck between the objectguard and the record lookup, and adds a separate ambient record consulted as a fallback, so
any namespace value source would have to sit behind the same gating rather than beside it.
References
implemented in
compute_rec.intentional, stable, decided from syntactic form alone, and required for third-party
transpilers.
(presumably through
namespaceobjects)", which is exactly what is missing here.constbindings.I'm happy to send a PR for this once #12101 is resolved, since both touch the same function.