Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to setup the TypeScript compiler for the library so that the unused modules will be cut off by Webpack in the dependents projects?

Preliminary explanations about subject library

I am sorry about taking your time by making you read this. I wrote it to answer the questions like "What are you doing?" and "Why are you doing this?".

The library consists from a large number of helper functions an classes. In this regard it's similar to lodash (check the structure of lodash), but unlike lodash the source code has been organized by multilevel directories. It's comfortable for the developers but could be not comfortable for the users: to import the desired function to project, the user must need to know where it is, e. g.:

import { 
  computeFirstItemNumberForSpecificPaginationPage
} from "@yamato-daiwa/es-extensions/Number/Pagination";

To solve this problem most of functions has been imported to index.ts and exported again from there. Now user can get desired function as:

import { 
  computeFirstItemNumberForSpecificPaginationPage 
} from "@yamato-daiwa/es-extensions";

Please note that all functions in index.ts (will be compiled by TypeScript to index.js) are intended for both BrowserJS and NodeJS. The functionality especially for BrowserJS is in BrowserJS.ts and especially for NodeJS in NodeJS.ts (currently is almost empty but the reexporting methodology is same).

Also, until this problem will be solved, I included the compiled JavaScript to the library repository (Distributable directory).

Problem

From now, @yamato-daiwa/es-extensions is the library and any project which depends on it is the consuming project.

I expected that all unused functions/classes of the consuming project will be cut off by Webpack optimizations. For example, in below case I expected that isUndefined function only will be left in Webpack bundle:

import { isUndefined } from "@yamato-daiwa/es-extensions"

const test: string | undefined = "ALPHA";
console.log(isUndefined(test));

But in reallity, Webpack left EVERYTHING from index.js of the library. I beautified the minified JavaScript built by Webpack; it is like:

(() => {
    "use strict";
    var e = {
            5272: (e, t) => {
                Object.defineProperty(t, "__esModule", {
                    value: !0
                }), t.default = function(e, t) {
                    for (const [a, n] of e.entries())
                        if (t(n)) return a;
                    return null
                }
            },
            7684: (e, t) => {
                Object.defineProperty(t, "__esModule", {
                    value: !0
                }), t.default = function(e, t) {
                    const a = [];
                    return e.forEach(((e, n) => {
                        t(e) && a.push(n)
                    })), a
                }
            },
  // ...

I suppose everyone understanding that is not acceptable especially for browser applications where each kilobyte on count.

How to solve this problem? The ideal solution (if it exists) will not touch the source files organization, just change the TypeScript configuration.

Repro

I created one more repository (repro) where you can try above example.

Experiment flow

  1. Get this repository by VCS
  2. Install dependencies as always (npm i command).
  3. Check the src/index.ts. It imports isUndefined function from the library and using it.
  4. Run npm run ProductionBuild
  5. Beautify the output index.js by tool like beautifier.io. You will see that whole library has been bundled while it's desired that only inUndefined has been bundled.

Musings about the cause

The first cause candidate is the using of reexportint pattern, exactly Source/index.ts, Source/BrowserJS.ts and Source/NodeJS. The compiled index.js looks like:

const isStringifiedNonNegativeIntegerOfRegularNotation_1 = require("./Numbers/isStringifiedNonNegativeIntegerOfRegularNotation");
exports.isStringifiedNonNegativeIntegerOfRegularNotation = isStringifiedNonNegativeIntegerOfRegularNotation_1.default;
const separateEach3DigitsGroupWithComma_1 = require("./Numbers/separateEach3DigitsGroupWithComma");
exports.separateEach3DigitsGroupWithComma = separateEach3DigitsGroupWithComma_1.default;

(Check full file)

If to import each function from it's individual module like import isUndefined from "@yamato-daiwa/es-extensions/TypeGuards/isUndefined" instead of import { isUndefined } from "@yamato-daiwa/es-extensions", no redundant code will be output. But as I already told, this solution is unacceptable because the library users must need to know where isUndefined and other function has been organized.

The other cause could be the output modules type. Currently it's a CommonJS. Here is the tsconfig.json of the library:

{
  "compilerOptions": {

    "target": "ES2020",
    "module": "CommonJS",
    "moduleResolution": "Node",

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,

    "removeComments": true,

    "outDir": "Distributable/",
    "declaration": true
  },

  "include": [ "Source/**/*" ]
}

According to hypothesis, depending on specific modules type the Webpack could bundle the code to monolithic structure where it's impossible to decompose and filter out some modules even if those has not been used.

Now all these (AMD, UMD, CommonJS) slowly become a part of history, but we still can find them in old scripts.

🌎 javascript.info

By the way, the TypeScript configuration in consuming project also could affect (included to repro). Currently it is:

{
  "compilerOptions": {

    "target": "ES2020",
    "strict": true,

    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "skipLibCheck": true,

    "baseUrl": "./",
    "paths": {
      "@SourceFilesRoot/*": ["./src/*"]
    }
  }
}
like image 950
Takeshi Tokugawa YD Avatar asked Jul 11 '21 23:07

Takeshi Tokugawa YD


People also ask

How does webpack handle TypeScript?

Webpack compiles a TypeScript file using ts-loader package which asks TSC to do the compilation. The result of this compilation will be JavaScript code that will be used to make a bundle. So in the end, we have a JavaScript bundle file with mangled import/export statements generated from . ts files.

What is TSC command in TypeScript?

Overview. The presence of a tsconfig. json file in a directory indicates that the directory is the root of a TypeScript project. The tsconfig. json file specifies the root files and the compiler options required to compile the project.

How does TypeScript compiler work?

Then after compiling the TypeScript code file into the JavaScript code file, it actually reads and interprets the JavaScript code. After interpreting the JavaScript code, the compiler compiles that code which thereafter produces the result.


1 Answers

I believe you at least need to set module so that ES6 or above is ouput. Possible values include

  • "es6"
  • "es2020"
  • "esnext"

The webpack documentation of tree-shaking says

Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.

*Note: moduleResolution: can remain as "node"

However, a correct module: setting is not necessarily sufficient, or may not even be desirable. See the section below:

  • side effects
  • imported libraries which are not ES6
  • if using babel, babel settings may conflict

side effects

The webpack 2 release came with built-in support for ES2015 modules (alias harmony modules) as well as unused module export detection. The new webpack 4 release expands on this capability with a way to provide hints to the compiler via the "sideEffects" package.json property to denote which files in your project are "pure" and therefore safe to prune if unused.

Actually, it appears that the default value for sideEffects: if NOT specified is false, so you don't really have to worry about that unless your ES6 code/module has certain side effects so that it SHOULDN'T be tree-shaken.
In fact setting sideEffects:false in the top level package.json is critical to enable tree-shaking in your project, as shown below.

(In a different project depending upon lodash, sideEffects was not critical. That may be because of the difference in library directory structure and index.js).

imported libraries

As an example, regular loadash will not be tree shaken because it is it is not es6. To enable tree shaking in lodash you have to add these packages and explicitly use the es6 version:

npm i lodash-es
npm i @types/lodash-es

and change you import statements from

import * as _ from "lodash"

to

import * as _ from "lodash-es"

See this SE answer for a discussion.

babel

The babel doc on its own settings for "module" says

modules

"amd" | "umd" | "systemjs" | "commonjs" | "cjs" | "auto" | false, defaults to "auto".

Enable transformation of ES module syntax to another module type. Note that cjs is just an alias for commonjs.

Setting this to false will preserve ES modules. Use this only if you intend to ship native ES Modules to browsers. If you are using a bundler with Babel, the default modules: "auto" is always preferred. modules: "auto"

By default @babel/preset-env uses caller data to determine whether ES modules and module features (e.g. import()) should be transformed. Generally caller data will be specified in the bundler plugins (e.g. babel-loader, @rollup/plugin-babel) and thus it is not recommended to pass caller data yourself -- The passed caller may overwrite the one from bundler plugins and in the future you may get suboptimal results if bundlers supports new module features.

So I guess if you really need babel (and you might not need it with webpack4) then you should ensure that "caller" is really specifying "false" so the ES6 stays as "ES6". In my setup that successfully minified, I was not using "babel".


EDIT: Ran the experimental compile provided on Gihub by the author, but according to the stdout diagnostics, there was not a difference using "module":"ESNext" compared to "CommonJS". Could it be that modules under @yamato-daiwa/ are not pre compiled to es6?


The Best Solution

Issue-WebpackDoesNotCuttOfTheUnusedFunctionality

package.json

{
  "private": true,
  "scripts": {
    "ProductionBuild": "webpack --mode production"
  },
  "sideEffects":false,
  "devDependencies": {
    "ts-loader": "9.2.3",
    "typescript": "4.3.2",
    "webpack": "5.38.1",
    "webpack-cli": "4.7.0",
    "webpack-node-externals": "3.0.0",
    "@yamato-daiwa/es-extensions": "file:../yamato_daiwa-es_extensions/Distributable/"
    }
}

"sideEffects":false, has been added.

tsconfig.json

{
  "compilerOptions": {

    "target": "ES2020",
    "strict": true,
    "module": "es2020",
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "skipLibCheck": true,

    "baseUrl": "./",
    "paths": {
      "@SourceFilesRoot/*": ["./src/*"]
    }
  }
}

Module was changed to "module": "es2020",

yamato_daiwa-es_extensions

tsconfig.json

{
  "compilerOptions": {

    "target": "ES2020",
    "module": "es2020",
    "moduleResolution": "Node",

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,

    "removeComments": true,

    "outDir": "Distributable/",
    "declaration": true
  },

  "include": [ "Source/**/*" ],
}

Module was changed to "module": "es2020",

Use the original index.ts file, with no changes

With the above settings I am getting an output index.js of size 39 bytes:

(()=>{"use strict";console.log(!1)})();
like image 199
Craig Hicks Avatar answered Nov 15 '22 20:11

Craig Hicks