5

My TypeScript enums are defined like this, as in this file:

export enum HueColors {
  "red"    = "hsl(0, 100%, 50%)",
  "orange" = "hsl(30, 100%, 50%)",
  // ...
  "pink"   = "hsl(330, 100%, 50%)",
}

export enum RGBExtended { /* ... */ }
export enum WebSafe { /* ... */ }

Setup/Config

// package.json
{
  ...
  "main": "./index.js",
  "types": "./index.d.ts",
  "files": [
    "**/*.{js,ts, map}"
  ],
  "sideEffects": false,
  "scripts": {
    ...
    "build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.config.js",
    "build": "cross-env NODE_ENV=production webpack --config config/webpack.config.js",
    ...
  },
  "babel": {
    "extends": "./config/.babelrc.json"
  },
  ...
  "devDependencies": {
    "@babel/core": "^7.14.8",
    "@babel/preset-env": "^7.14.8",
    "@types/jest": "^26.0.24",
    "@types/node": "^16.4.0",
    "@typescript-eslint/eslint-plugin": "^4.28.4",
    "@typescript-eslint/parser": "^4.28.4",
    "copy-webpack-plugin": "^9.0.1",
    "cross-env": "^7.0.3",
    "eslint": "^7.31.0",
    "eslint-plugin-jest": "^24.4.0",
    "jest": "^27.0.6",
    "prettier": "^2.3.2",
    "terser-webpack-plugin": "^5.1.4",
    "ts-jest": "^27.0.4",
    "ts-loader": "^9.2.4",
    "ts-node": "^10.1.0",
    "typedoc": "^0.21.4",
    "typescript": "^4.3.5",
    "webpack": "^5.46.0",
    "webpack-cli": "^4.7.2"
  }
}
// config/.babelrc.json
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        },
        "modules": false
      }
    ]
  ]
}
// config/tsconfig.json
{
  "compilerOptions": {
    "target": "ES6",
    "module": "ES6",
    "lib": ["DOM", "DOM.Iterable", "ES2017"],
    "moduleResolution": "node", 
    "outDir": "../dist", 
    "noEmit": false,
    "declaration": true, 
    "strict": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "removeComments": false,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["../src"],
  "exclude": ["../node_modules", "../tests", "../coverage", "../src/debug.ts"]
}
// config/webpack.config.js

/* eslint-disable @typescript-eslint/no-var-requires */
const CopyPlugin = require("copy-webpack-plugin");

const path = require("path");

const basePath = path.resolve(__dirname, "../");

module.exports = {
  entry: path.join(basePath, "src", "index.ts"),
  mode: process.env.NODE_ENV,
  devtool: process.env.NODE_ENV === "production" ? "source-map" : false,
  module: {
    rules: [
      {
        test: /\.ts$/,
        loader: "ts-loader",
        options: {
          configFile: path.join(__dirname, "tsconfig.json")
        },
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new CopyPlugin({
      patterns: [
        ... // not important for question
      ]
    })
  ],
  optimization: {
    minimize: process.env.NODE_ENV === "production",
    minimizer: [
      (compiler) => {
        const TerserPlugin = require("terser-webpack-plugin");
        new TerserPlugin({
          terserOptions: {
            ecma: 5,
            mangle: true,
            module: false
          }
        }).apply(compiler);
      }
    ],
    usedExports: true,
    sideEffects: true,
    innerGraph: true
  },
  stats: {
    usedExports: true,
    providedExports: true,
    env: true
  },
  resolve: {
    extensions: [".ts"]
  },
  output: {
    filename: "index.js",
    path: path.join(basePath, "dist"),
    library: "colormaster",
    libraryTarget: "umd",
    globalObject: "this",
    clean: true
  }
};

Development Build Output

I see the following in the console:

...
./src/enums/colors.ts 17.6 KiB [built] [code generated]
    [exports: HueColors, RGBExtended, WebSafe]
    [only some exports used: HueColors] // ← indicates that tree shaking should occur in production build
webpack 5.46.0 compiled successfully in 2368 ms

I see the following in the generated dist folder output:

// dist/index.js → mode === development 

/***/ "./src/enums/colors.ts":
/*!*****************************!*\
  !*** ./src/enums/colors.ts ***!
  \*****************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "HueColors": () => (/* binding */ HueColors)
/* harmony export */ });
/* unused harmony exports RGBExtended, WebSafe */ // ← indicates that tree shaking should occur in production
var HueColors;
(function (HueColors) {
    HueColors["red"] = "hsl(0, 100%, 50%)";
    ...
    HueColors["pink"] = "hsl(330, 100%, 50%)";
})(HueColors || (HueColors = {}));
var RGBExtended;
(function (RGBExtended) {
    RGBExtended["maroon"] = "rgb(128,0,0)";
    ...
    RGBExtended["white"] = "rgb(255,255,255)";
})(RGBExtended || (RGBExtended = {}));
var WebSafe;
(function (WebSafe) {
    WebSafe["#000000"] = "rgb(0,0,0)";
    ...
    WebSafe["#FFFFFF"] = "rgb(255,255,255)"; 
})(WebSafe || (WebSafe = {}));

Production Build Output

However, in the production build output, I see the following: Production build output

Which clearly still includes the unused exports.

What can be done to circumvent this issue?

Solution

Thanks to @Jeff Bowman's extensive response, we were able to deduce that the root cause was TypeScript compiling enum into an IIFE.

Simply replacing enum variable with const (Record Utility) fixed the issue and Tree Shaking was visible in the production bundle.

lbragile
  • 7,549
  • 3
  • 27
  • 64
  • I've rephrased your question title and added a link and excerpt to try to make the question more understandable and searchable. I hope you endorse the result; please edit/revert/iterate as you please. – Jeff Bowman Aug 10 '21 at 16:52
  • Thank you. I've added the solution that we discussed to help others that come across this issue. – lbragile Aug 11 '21 at 03:11

2 Answers2

5

This is due to Terser being unable to reason about the side-effects in your colors.ts enum, so Terser keeps all three definitions even though it only exports one of them.

If these weren't transpiled TypeScript enums, I'd recommend to simplify the declarations, ideally by marking each function /*#__PURE__*/ and making it return its intended value. However, since they are TypeScript enums, you might need to convert them to object literals as const, are certainly easier for Terser to reason about and are likely sufficient for your needs.


If I'm reading your output right, the arrays you're trying to remove are present in both development and runtime builds; you've omitted them with "..." but they're there.

According to your package.json you are using both sideEffects and usedExports of Webpack's tree-shaking feature set. sideEffects correctly asserts that you aren't changing anything aside from your exports, so Webpack can safely skip your whole module if your project consumes none of its exports. However, usedExports might not be as smart as you would hope:

usedExports relies on terser to detect side effects in statements. It is a difficult task in JavaScript and not as effective as straightforward sideEffects flag. It also can't skip subtree/dependencies since the spec says that side effects need to be evaluated.

It seems that for both development and production Webpack is smart enough to detect that your HueColors is the only export you consume, but Terser is not smart enough to determine that each self-initializing IIFE is free of side-effects that would affect the others. Technically, as a human, I can't reason about it either: Some other piece of code might have changed the Object or Array prototype in a bizarre way, even if your functions didn't use inline assignment or modify same-named shadowed variables of an enclosing scope in your IIFEs.


With an in-browser copy of terser I've been able to reproduce your problem.

First of all, switching to const object literals would be completely effective:

const foo = {foo: "foo"};
const bar = {bar: "bar"};
const baz = {baz: "baz"};

window.alert(foo);

// output: window.alert({foo:"foo"})
// correctly minifed

The same definitions, in your format, exhibit the behavior you're trying to avoid:

var foo;
(function(x) {
  x.foo = "foo";
})(foo || (foo = {}));
var bar;
(function(x) {
  x.bar = "bar";
})(bar || (bar = {}));
var baz;
(function(x) {
  x.baz = "baz";
})(baz || (baz = {}));

window.alert(foo);

// output: o,n,a;(o||(o={})).foo="foo",function(o){o.bar="bar"}(n||(n={})),function(o){o.baz="baz"}(a||(a={})),window.alert(o)
// incorrectly minified; foo, bar, and baz all survive

It's not sufficient to merely avoid the inline definition, though it's a good start:

var foo = {};
(function(x) {
  x.foo = "foo";
})(foo);
var bar = {};
(function(x) {
  x.bar = "bar";
})(bar);
var baz = {};
(function(x) {
  x.baz = "baz";
})(baz);

window.alert(foo);

// output: o={};o.foo="foo";!function(o){o.bar="bar"}({});!function(o){o.baz="baz"}({}),window.alert(o)
// incorrectly minified: definitions remain, but
//     ! shows how terser is just looking for side effects

It is sufficient if you make each function return a value and you mark the functions with /*#__PURE__*/ as in the webpack documentation and terser documentation. That won't help with your enums, but does indicate how the output could be tweaked to satisfy Terser.

var foo = /*#__PURE__*/ (function() {
  var x = {};
  x.foo = "foo";
  return x;
})();
var bar = /*#__PURE__*/ (function() {
  var x = {};
  x.bar = "bar";
  return x;
})();
var baz = /*#__PURE__*/ (function() {
  var x = {};
  x.baz = "baz";
  return x;
})();

window.alert(foo);

// output: let o=function(){var o={foo:"foo"};return o}();window.alert(o)
// correctly minified, though longer than the literal example
Jeff Bowman
  • 90,959
  • 16
  • 217
  • 251
  • thank you so much for the extensive answer. Here is a link to an outdated enum file that should give an idea of what it looks like (forgot to add it in my question): https://github.com/lbragile/ColorMaster/blob/develop/src/enums/colors.ts. The IIFE notation seems to be generated by the TypeScript compiler during Webpack build and I cannot (not sure how) edit it during the production build. I do notice that you mentioned using objects (TypeScript Record Type) rather than enums, is that to avoid generating the IIFE syntax, making minification easier to achieve? – lbragile Aug 10 '21 at 06:06
  • 1
    You're welcome! It's obvious in hindsight; I hadn't put two and two together that this is transpiled TS enum syntax. [TS enums are IIFEs](https://stackoverflow.com/q/47363996/1426891), apparently (ironically) for more efficient minification. Between that, the [prescribed runtime representation](https://www.typescriptlang.org/docs/handbook/enums.html#enums-at-runtime), and modify-in-place open-endedness, it all makes sense. FWIW, [the Handbook itself suggests an object `as const`](https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums), and that would solve your problem. – Jeff Bowman Aug 10 '21 at 06:21
1

In addition the Jeff's answer. There are some simple ways to avoid un-tree-shakeable enums.

First is using const enum instead of enum. It's perfect for internal code (not exported outside your library), but may cause inconveniences in external code (for example const enums are unavailable in JavaScript and not compatible with module isolation).

export const enum HueColors {
  "red"    : "hsl(0, 100%, 50%)",
  "orange" : "hsl(30, 100%, 50%)",
  // ...
  "pink"   : "hsl(330, 100%, 50%)",
}

Second is using a plain object and a type with the same name:

export const HueColors = {
  "red"    : "hsl(0, 100%, 50%)",
  "orange" : "hsl(30, 100%, 50%)",
  // ...
  "pink"   : "hsl(330, 100%, 50%)",
}
export type HueColors = typeof HueColors[keyof typeof HueColors]

// ...

// Acts like an enum:
const color: HueColors = HueColors.red

// The only difference is that it allows using literals
// when the type is expected. For example, this will fail
// if HueColors is an enum:
const color: HueColors = 'hsl(0, 100%, 50%)'

Third is avoiding the enums. This seems to match the semantics of your example.

export const HueColors = {
  "red"    : "hsl(0, 100%, 50%)",
  "orange" : "hsl(30, 100%, 50%)",
  // ...
  "pink"   : "hsl(330, 100%, 50%)",
}
// or
export const HueColorRed = 'hsl(0, 100%, 50%)'
export const HueColorOrange = 'hsl(30, 100%, 50%)'
export const HueColorPink = 'hsl(330, 100%, 50%)'
Finesse
  • 9,793
  • 7
  • 62
  • 92