What is the best way to structure a multi-function NPM package for clean imports and efficient tree-shaking?
Embracing Modern ES Modules
For modern libraries that need to be compatible with both Node.js and browsers, it is recommended to develop your source code in TypeScript and provide an ES module distribution.
This approach aligns with the idea that new libraries should prioritize modernity, simplicity, and efficiency — essentially, your library should consist of a directory of ES modules accessible publicly.
Your package.json file should contain:
"type": "module",
"main": "dist/main.js",
"module": "dist/main.js",
"files": [
"dist",
"source"
],
"scripts": {
"prepare": "tsc",
"watch": "tsc -w",
},
"devDependencies": {
"typescript": "^3.8.3"
},
You may also configure your tsconfig.json file with entries like these:
"baseUrl": ".",
"rootDir": "source",
"outDir": "dist",
"target": "esnext",
"lib": ["esnext", "dom"],
"module": "esnext",
"experimentalDecorators": true,
"sourceMap": true,
"declaration": true,
All concerns related to optimization such as bundling, minification, tree-shaking, transpilation, and polyfilling are left to the consumer applications to handle, as providing for these concerns would result in redundant bloat within the libraries.
Legacy CommonJS applications can easily utilize an ESM adapter to consume the ES module distribution.
If absolutely necessary (although it's highly advisable to reconsider), you can create a dual ESM + CJS hybrid package by running a parallel build step using
tsc -- --module=commonjs --outDir dist-cjs
and then setting the package.json entry as
"main": "dist-cjs/main.js"
, thus supporting both CommonJS and ESM simultaneously.
Specific Recommendations
I'm tasked with creating an NPM package for shared functionality across frontend projects in our organization. However, I'm unsure about the proper way to proceed. We use TypeScript, and while tsdx seems helpful, it doesn't address the structuring aspect for a utility-oriented package.
The ES module strategy I advocate works well for isomorphic code, ensuring seamless integration between frontend and backend node projects.
Regarding the 'main' field in package.json, how should it be handled when there isn't a specific export or function that serves as a logical entry point for the package?
Omit specifying the 'main' and 'module' fields in package.json; it's considered a best practice to avoid designating a single entry point as the "main" — instead, encourage consumers to select modules explicitly.
Utilizing direct imports as illustrated above is preferable to using an index file that consolidates potentially unnecessary modules, eliminating the need for extensive tree-shaking.
Should every public function within the package be exported individually? And if so, how does this impact tree-shaking and other optimizations?
Instruct npm to publish the 'dist' directory containing ES modules from your package.json 'files' array — including 'source' for improved debugging via sourcemaps. Directly importing modules removes tree-shaking constraints since there is no fat index file aggregating possibly unwanted modules.
Often, the packaged path includes 'dist' or 'lib,' which I find visually unappealing. Is there a way to avoid this?
While this aesthetic concern was initially bothersome, embracing the distinction between 'dist' and 'source' contributes to clarity. The simplicity of packages directly from the root enhances user experience.
How can one efficiently structure a multi-function NPM package to achieve clean imports and robust tree-shaking capabilities?
The suggested strategy in this response aims to meet your requirements effectively.
Can anyone recommend simple examples of well-structured libraries on GitHub that demonstrate these principles?
While not an exhaustive list, repositories like authoritarian, renraku, metalshop, and shopper illustrate these practices. Also, established libraries like lit-element
serve as good references, despite being partly rooted in the CommonJS realm.