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 { /* ... */ }
// 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
}
};
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 = {}));
However, in the production build output, I see the following:
Which clearly still includes the unused exports.
What can be done to circumvent this issue?
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.
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 straightforwardsideEffects
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