Skip to content

TypeScript enum transform does not fold namespace member access (N.foo, N.M.foo) #12102

Description

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 causecrates/swc_ecma_transforms_typescript/src/ts_enum.rs, compute_member.
Two independent gaps:

  1. 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.

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions