Introduction and Disclaimer
When you experience the misery of being a JavaScript developer for long enough, you might have the bright idea of contributing to the npm ecosystem by creating your own game changing npm package. You enter tutorial quicksand and then you realize - “this is way too complicated.”
It’s true, there’s a lot of information online about creating npm packages; much of it conflicting. But fear not. I have reached enlightenment and will share what I have learned.
This guide consists of three sections:
-
An overview of terminology for JavaScript and its module systems (e.g., ECMA, CJS, ESM, etc.).
Note: section 1 has been separated to an independent article found here that also highlights the history of JavaScript. I highly recommend reading my previous article to fully understand the terminology used in this article.
-
A walkthrough to set up a development environment configured with type safety, code formatting, dependency management, unit testing, and automatic releases.
-
An opinionated recipe for making scalable npm packages that have wide support for users by delivering the most common JavaScript module systems, CommonJS and ES Modules.
While there is no “one size fits all” solution, this guide will present topics that should be considered when authoring an npm package, as well as provide justifications for tech/design decisions I’ve made to help give you informed opinions on how to make your next Game Changing Million Dollar npm Package (GCMDNP).
Follow along with my npm starter-kit repo.
Note: see pull requests and issues for more details on my workflow.
JavaScript Version and CommonJS vs ES Module
When we make JavaScript packages we must consider the JavaScript version and module systems we ship to users. Yeah, that’s right - not all JavaScript is the same.
The JavaScript version provides the syntax and APIs you and your users can use. The module system determines how your code is imported/exported and which users can use your package.
IMPORTANT
This article assumes that you understand the different JavaScript version and module system terminology. Understanding the terminology detailed in my previous article is a prerequisite to fully understand the contents of this article.
If hearing that “not all JavaScript is the same” is a surprise to you, or if you’re just interested in reading about the history of JavaScript, please see my previous article here: eyas.ca/blog/clarifying-javascript-terminology-and-history/
JavaScript Versions
Below are excerpts from my previous article. See more here.
The “JavaScript version” is often referred to by the ECMA-262 edition, the ECMAScript version, the ECMAScript year, or the JavaScript version.
Keep in mind which JavaScript version you use and ship to your users:
ECMA-262 | ECMAScript edition | ECMAScript year | JavaScript (ES compliant) |
---|---|---|---|
Edition 1 | ES1 | ES1997 | JavaScript 1.3 |
Edition 2 | ES2 | ES1998 | JavaScript 1.3 |
Edition 3 | ES3 | ES1999 | JavaScript 1.5 |
Edition 4 | ES4 | (cancelled version) | not completed |
Edition 5 | ES5 | ES2009 | JavaScript 1.8.5 |
Edition 6 | ES6 | ES2015 | |
Edition 7 | ES7 | ES2016 | |
Edition 8 | ES8 | ES2017 | |
Edition 9 | ES9 | ES2018 | |
Edition 10 | ES10 | ES2019 | |
Edition 11 | ES11 | ES2020 | |
Edition 12 | ES12 | ES2021 | |
Edition 13 | ES13 | ES2022 | |
Edition 14 | ES14 | ES2023 | |
Upcoming version | ESNext |
JavaScript Module Systems
Below are excerpts from my previous article. See more here.
“Should we author npm packages that support CommonJS module users?”
In a GitHub issue with no consensus, core Node.js contributor James M. Snell writes in March 2021 that CommonJS is not widely adopted but is also not close to being deprecated in Node.js.
Given CommonJS will be available as a module system for the foreseeable future, we should support those who choose to develop in CommonJS as well as the soldiers left behind in tech dept.
This guide will show you an easy method to deliver CommonJS to your users.
“Should we author npm packages that support ES Module users?”
In short, yes. Whether you like ES Modules or not, runtimes will support the ES Module implementation for the foreseeable future. We should support developers who choose to develop in ES Modules.
This guide will show you an easy method to deliver ES Modules to your users.
Using CommonJS and ES Modules
Your code will be determined as either CommonJS or ES Module by your runtime environment using the following methods (sorted by priority):
CommonJS
- acceptable file extensions:
.js
,.cjs
package.json
attribute:"type":"commonjs"
- an empty
"type"
attribute defaults to"commonjs"
- an empty
- the module import/export methods described here
ES Module
- acceptable file extensions:
.js
,.mjs
package.json
attribute:"type":"module"
- the module import/export methods described here
Tree Shaking
Tree Shaking refers to the bundler removing unused/dead code from the final build. This doesn’t apply to this package directly, but it does apply for any user that would consume this package. I.e., if a user only wants to use a portion of the package, the unused code should be removed rather than waste space by being bundled in their project. Therefore the package should be written in a way that can be easily tree shaken.
The CommonJS problem
You might find that online resources make conflicting claims:
- Commonly, “Tree Shaking is only possible with ES Modules” or “It relies on the static structure of ES2015 module syntax”
- Far less common, “Tree Shaking is sometimes possible with CommonJS”
The reason Tree Shaking is commonly associated with ES Modules is because the algorithm used to strip unused/dead code from the project will perform static analysis to determine which code is used or declared in the global scope at the time of bundling.
This is almost impossible to determine in CommonJS because one of CommonJS’ distinguishing features is the ability to conditionally import. E.g.,
In the example above, whether ./index
is imported is determined at runtime, so an algorithm can’t determine whether the import will ever resolve and will therefore bundle ./index
just in case. This is the reason that some bundlers don’t even attempt to tree shake CommonJS and therefore ./index
will be bundled just in case even if it isn’t conditionally imported.
How to defy god
There are usually plugins for modern bundlers that will perform advanced static analysis to remove unused/dead CommonJS code. You’ll have to do research on solutions that work for you and rely on open-source maintainers. For example Webpack’s webpack-common-shake.
This project will supply an import subpath (discussed later) that will allow users to narrow what they import regardless of whether unused/dead code has been tree shaken.
Writing Tree Shakable Code
I recommend reading Simohamed Marhraoui’s article at LogRocket, “Tree shaking and code splitting in webpack”. The article extensively explains how to optimize your code for tree shaking when using things like classes, namespaces, side effects, etc.
Some good rules of thumb for tree shaking are as follows:
-
Use named exports when exporting code (discussed further here).
-
Do not bundle code into one file (discussed further here).
-
Do not write code with side effects.
A side effect is code that performs something other than exposing exports. An example of a common side effect is adding a polyfill to your code, or any other impure code.
It’s best to add the following attribute to the
package.json
configuration when the package doesn’t contain a side effect: -
Export the package with a wildcard subpath entry point.
Subpath entry points make it possible for the user of the package to make a relative import to the exact module they need within the package. This lets users take advantage of tree shaking as well as dynamic importing to perform lazy-loading (aka code-splitting). Christian Gonzalez writes an insightful article describing how subpath imports fix tree shaking in circumstances where code doesn’t tree shake correctly.
Export pattern
When exporting functions, variables, classes, etc. use the patterns:
The main reasons to avoid the default
pattern are:
-
exporting as
default
exports as value rather than a live reference- this could have unexpected behaviours, specifically if your module has side effects or is exporting
const
/let
scoped anonymous functions (that won’t be hoisted).
- this could have unexpected behaviours, specifically if your module has side effects or is exporting
-
transpilers could have unexpected behaviors and create patterns such as1:
-
tooling is better for named exports because autocomplete can tell which package you’re importing based on the destructured element.
-
it’s easier for compilers to statically analyze which code is unused for tree shaking.
-
Most importantly, the people who use your package are going to spit in your oatmeal.2
Use index
files as your entry point (barrel files)
Node.js has a special relationship with index
files. You’ll notice in the example code that I do a trick where imports don’t define the specific file, but rather only specify the file directory to be imported:
This is possible because this project will use TSConfig’s bundler
module resolution which will perform directory module resolution from the CommonJS module system (even though the import statement is ESM syntax) to specifically look for index.js
or index.node
in the case where a directory path is supplied as an import rather than a file path.
Congratulations!!
Time to build the package!
Setting up your environment
Note: This guide will assume you have Node.js and npm set up on your system
GitHub
Initialize your git repo on GitHub. I prefer creating a repo on GitHub and connecting via the https address, but another common method is to clone the repo.
-
Initialize a repo using the https address with the following command:
-
If they don’t exist already, use the following command to create a
.gitignore
,README.md
and aCONTRIBUTING.md
:The
CONTRIBUTING.md
document should be the guidelines for how you and others should create commit messages and pull requests. Examples of contributing files:The
README.md
document will be the landing page for the package on the npm website. It’s standard to format theREADME.md
with the following sections:Any further information about the project should be located in a GitHub wiki.
Initiate the package manager
This guide will use npm to manage our package’s metadata:
If you have no idea how to answer the prompts you can leave everything as default, but you should have the information required for name
, description
, and author
.
Note: IF YOU PLAN TO PUBLISH THE PACKAGE TO THE GITHUB REGISTRY YOU NEED TO PREFIX YOUR PACKAGE NAME WITH YOUR USERNAME!!! I.e.,
@spiltbeans/package-name
Installing packages
There are a couple of options for installing dependencies into the project:
Note: in this context “user” is the developer using your GCMDNP
dependency type | dependencies | devDependencies | peerDependencies | bundleDependencies |
---|---|---|---|---|
dependency behaviour | will be downloaded in the user’s node_module folder. If the user already has a different version of the dependency installed, install the copy of the dependency in a node_module folder within your GCMDNP | will only be downloaded in your GCMDNP when in development and will be left behind when your package is installed by the user | will be downloaded in the user’s node_module folder. If the user already has the dependency but a different version, warn the user that they have a different version, but don’t enforce a new installation UNLESS the dependency has a major version difference (i.e., 1.x.x vs 2.x.x) | will be bundled with your GCMDNP and therefore automatically installed in the user’s project when installing your package |
Only JavaScript and type declarations will be shipped to the user when they install the package. Since none of the project configurations or even the TypeScript is sent to the user we should be mindful of how the dependencies are installed so that the user doesn’t download useless dependencies in their own project.
Refer to npm docs on how to use npm install flags to mark dependencies as dev, peer, bundle, etc.
File structure
My preferred structure looks like this:
This structure is maintainable for my workflow because it separates the logic cleanly, which allows for code/type modularity where I can just create the files in lib
or @types
as needed.
Generate my preferred file structure by running the following command from the project root:
Note: the rest of this guide uses this file structure, but use what works for you!
Husky
Husky is a tool to manage and trigger Git Hooks, which basically do “some defined action” when there is a commit. This package will create some Git Hooks later.
-
Install Husky at the root of the project:
Note: the
--save-dev
flag (-D
flag for short) is used to install Husky asdevDependencies
. -
Add a prepare script to the
package.json
. When Husky installs itself into the./.husky
directory it adds a.gitignore
that will ignore all of its installed files, so Husky needs to reinstall itself on every npm installation: -
Trigger the actual Husky install:
Commit linting
To ensure that commit messages are consistent, this project will use commitlint and Husky to ensure Angular commit guidelines are being followed.
-
Install commitlint at the root of the project:
-
Create a config file for commitlint with commit conventions:
-
Add a Husky hook to run commitlint on every commit message:
Version control
Project versions are an important piece of metadata for npm packages. There are many options to manage your package version according to semantic versioning. I recommend using release-please by Google or Changesets.
Some differences to consider:
release-please | changesets | |
---|---|---|
version bumps | release-please will only bump a version if your commit is a BREAKING CHANGE , feat , or fix | You manually bump the version using a changesets command |
changelogs | release-please auto-generates a CHANGELOG file based on your commit messages | You manually write the contents of the CHANGELOG file using a changesets command |
bumping the repo | release-please bumps your repo via an automated pull request | You manually bump your repo with a commit. You can set up your own GitHub Action to have an automated pull requests |
Some developers believe changesets are more flexible. I’ll show both and you can choose what suits you best.
Changesets
-
Install changesets at the root of the project:
-
Bump the version with the following command:
Note: when you follow the cli instructions you will find an auto-generated file in the
./changeset/<some-random-human-readable-changeset-name>.md
. -
Apply version bump
Manual
-
Use the following command:
-
Commit the modified CHANGELOG.md and package.json
Automated
- Set up a GitHub Action that will automatically create a pull request to bump our versions. Changesets’ documentation has a great tutorial.
-
Note: you must also allow GitHub Actions to read, write, and create pull requests in your GitHub repo settings:
Note: Changesets flatten version bumps of the same type!. Ensure you merge the pull request or manually bump the package version often if you want to have granular bumps rather than the flattening effect.
release-please
- Create a Personal Access Token (PAT) with the following permissions:
-
Add your PAT to your GCMDNP repo as an Action Secret
-
Add a GitHub Action to your project
-
Configure the GitHub Action as follows:
-
Merge release-please pull requests to apply version bumps to the repository
Note: you must also allow GitHub Actions to read, write, and create pull requests in your GitHub repo settings:
Note: release-please flattens version bumps of the same type!. Ensure you merge the pull request often if you want to have granular bumps rather than the flattening effect.
Linting
Prettier
Prettier is a package that formats the code. It’s not used for type checking or finding code errors. Use ESLint and/or TypeScript.
Prettier can enforce rules such as having your code use tabs, placing semi-colons on each line, etc.
-
Install Prettier at the root of the project:
-
Create a config file for Prettier:
-
(optional) You can use a Prettier plugin to organize import statements:
-
There are many options for customizing Prettier to make code clear for you! An example config I like:
-
Add format scripts to the
package.json
to run Prettier on the project files:Note: the
--cache
flag will apply the scripts to changed files only. Remove the flag if you want the script to apply to the entire project.
ESLint
ESLint is a package that lints the code. It is used for finding code errors!
ESLint can enforce rules that ensure the code runs without a runtime, syntax, or logic error, such as whether to use ===
or ==
equality.
-
Install ESLint by running the following command at the root of the project:
Configure ESLint with what works best for your project, but my recommended prompts are as follows:
-
Add the following override to your
package.json
to ensure that ESLint plugins are forced to use the version of ESLint you’ve installed rather than the version the plugin depends on.Note: overrides are useful if plugin maintainers don’t update their dependencies. This step is recommended by ESLint.
The CLI generates almost everything we’re going to need. If you selected the “Does your project use TypeScript” option you’ll find the typescript-eslint package already configured, which lets ESLint and Prettier be compatible with TypeScript. Nice!
ESLint can be used as a formatter, but that should really be left to a dedicated formatter like Prettier.
-
To disable rules that conflict with Prettier, we can use the eslint-config-prettier package:
-
Import
eslint-config-prettier
and add it to the end of the ESLint configuration array: -
Add a lint script to the
package.json
to run ESLint on our files:
There are many options for customizing ESLint if you’re adventurous, but the default rules are suitable for this guide.
TypeScript
“TypeScript is just overhead!”
You shouldn’t feel obligated to write your code in TypeScript, but it’s widely adopted and not that much work to get running.
Despite what the TypeScript haters say, we should consider the developer experience and “how easy it is for someone to use my work.” If type declarations help developers use your package - then use it.
A rising alternative to TypeScript is to use JSDoc which essentially uses code comments to enforce type safety, which eliminates the transpile step in building a package since the source code is also JavaScript. For more information comparing the pros and cons of JSDoc to TypeScript I recommend reading Nwalozie Elijah’s article at OpenReplay, “JSDoc: A Solid Alternative To TypeScript”.
If you aren’t familiar with TypeScript AT ALL, fear not! You can always ditch the type system and author your *.ts
files in JavaScript and the transpiler will still emit types using type inference.
Ok ok, fine just use
*.js
files. Whatever. I’m still using.ts
.
“But we already have ESLint??!”
ESLint should be linting your code for code correctness, like enforcing the appropriate equality checks (===
vs ==
), enforcing unused variables, etc. TypeScript is used for defining the shape of objects, other variables, and functions within the code to be able to properly handle data and avoid runtime errors.3
-
Install TypeScript and Node.js types at the root of the project:
-
There are fr fr many options for the TypeScript config an example that suits this project can be generated with the following command:
You can clean up the comments by hand, or you can be a programmer (source):
The
tsconfig.json
should now contain the following configuration: -
Add the source directory to the config to tell TypeScript where to do the type checking:
-
Add a lint script to the
package.json
to run TypeScript on the project files:
Configuring you your tsconfig.json
is the “choose your own adventure” part of this guide. TypeScript configurations are different for most projects. (i.e., is your package only exporting JavaScript? Is your package exporting jsx
? Are you mixing JavaScript and TypeScript? Are you referencing your modules with or without file extensions? etc.).
Let’s walk through why I made these decisions for my tsconfig.json
file:
Things that affect the linting
lib
: the JavaScript library used for the type-checking (ECMA version, DOM, etc.).- I want to develop with the latest JavaScript library available. We won’t have to worry about user support for
esnext
sincelib
version doesn’t get emitted.
- I want to develop with the latest JavaScript library available. We won’t have to worry about user support for
allowJs
: let JavaScript interop work in TypeScript files.- I may want to use JavaScript in this package.
forceConsistentCasingInFileNames
: enforces case-sensitive file names based on how other files are named.- Personal preference to have this. I prefer consistent file name casing in my project.
strict
: enforces the main type checking benefits of TypeScript.- Some say that if you disable
strict
you might as well not use TypeScript.
- Some say that if you disable
noUncheckedIndexedAccess
: will throw warnings when indexing an array/object such as “index may beundefined
”.- This option is useful, especially concerning input you don’t control (i.e., Promises, user input, etc.) where you should consider handling
undefined
orindex does not exist
responses.
- This option is useful, especially concerning input you don’t control (i.e., Promises, user input, etc.) where you should consider handling
skipLibCheck
: will not check over the TypeScript declaration files in your project.- You shouldn’t be creating declaration files in your project, so this is mainly going to skip over the declaration files in your
node_module
dependencies. I don’t want to check over dependency types which are likely already type-checked.
- You shouldn’t be creating declaration files in your project, so this is mainly going to skip over the declaration files in your
moduleDetection
: will tell TypeScript that all of your files are modules (ESM or CJS) with their own scope, as opposed to scripts that declare variables in the global scope.force
will tell TypeScript to assume your files are modules rather thanauto
which checks each file’s import/export statement and thetype
value in thepackage.json
to determine if the file is CommonJS or ES Module.
moduleResolution
: will tell TypeScript how to find the file for an imported module.bundler
has the characteristics of:import
condition (from ES Module syntax).- does not require file extension (from CommonJS syntax).
- performs directory module resolution. I.e., I can import
./something
and the runtime will look for./something/index.ts
becauseindex
is a special filename. (from CommonJS syntax).
- Because we will be emitting CommonJS and ES Module I want the source code to have non-strictly defined imports and have the bundler figure out module paths with file types.
resolveJsonModule
: let JSON interop just work in TypeScript files.- I may want to use JSON in this package.
isolatedModules
: Typescript will check whether individual files rely on other files to transpile. E.g.,module a
relies on something in the global type system (i.e., enum, namespace, etc.). This is because the types affect how the code is transpiled in some circumstances and often bundlers will transpile one file at a time and therefore won’t be able to access the full type system.- This should be enabled as this project uses a bundler.
verbatimModuleSyntax
: does two things- (1) TypeScript will not emit
module b
ifmodule a
only imports a type frommodule b
and no other code. - (2) TypeScript does not allow you to mix and match import/export styles between the source code you write and the emitted code. I.e., if you’re writing an ES Module and emitting as CommonJS, you cannot use ES Module syntax for importing.
- TypeScript usually does magic to let mix-and-match import/exports work but it’s become less common to let that magic happen in modern projects.
- (1) TypeScript will not emit
Things that affect the compiled JavaScript
target
: the JavaScript version to be emitted by the transpileres2022
is a widely supported ECMAScript edition (ES13) by runtimes, so it’s unlikely this project will use features that will be unsupported or require polyfills.
module
: whether TypeScript emits your modules as CommonJS or ES Modules.- Because we are using a bundler and our
moduleResolution
isbundler
we must select anes*
module type. This is not a big deal since our bundler will emit the correct module import/export statements.
- Because we are using a bundler and our
declaration
: tells TypeScript to emit type declaration files.- We want to disable this because we’ll be emitting declarations via a separate command, not through config. (discussed later)
esModuleInterop
: will TypeScript emit some extra JavaScript to make sure that if you’re using CommonJS/AMD/UMD modules as if they’re ESM, it will still work by whatever uses your transpiled code.- If this is disabled you might occasionally have errors because ESM and CJS handle default import/exports differently (
require("").default
moment).
- If this is disabled you might occasionally have errors because ESM and CJS handle default import/exports differently (
I want to note at this point that many blogs, tutorials and other accessible mediums to find information on TypeScript compiler options do a poor job of explaining what the options do. Even the TypeScript TSConfig Reference documentation is confusing at times. If you are confused about TSConfig compiler options, my recommendation is to look at the source material.
For example, below are three references I used to fully understand what verbatimModuleSyntax
does, including the actual TypeScript pull request and proposal. Sometimes the source is just good:
- “Announcing TypeScript 5.0” by Daniel Rosenwasser (Microsoft)
- “Proposal: deprecate
importsNotUsedAsValues
andpreserveValueImports
in favor of single flag” by Andrew Branch - “Add
verbatimModuleSyntax
, deprecateimportsNotUsedAsValues
andpreserveValueImports
” by Andrew Branch
Lint-staged
We can use lint-staged to make sure our linters run on each commit.
-
Install lint-staged at the root of the project:
-
Create a config file for lint-staged:
-
Add the npm linting commands to the config instructions which will run on
*.ts
files. So far this guide has the following: -
Add a hook to Husky to run lint-staged on before every commit:
Maintain project dependencies
Instead of manually updating our project dependencies, we can automate this process by adding a GitHub bot to our project.
Renovate
Note: Renovate has a tutorial with pictures here
-
Install the Renovate bot through the GitHub Marketplace: https://github.com/apps/renovate
-
Complete the registration with Mend.
-
Complete the organization registration.
- Select interactive mode to ensure the Renovate bot sends upgrade pull requests.
- Use the “With Config File” option to ensure the bot is only active on repositories that you select.
-
Create a config file for renovate:
-
There are many options for customizing Renovate. The default is fine in most instances:
Now we will receive automated emails and pull requests when dependencies in our project can be updated.
You can find additional configuration options on the RenovateBot website
Testing
Jest
Jest is a package that lets us create test harnesses to test the correctness of our code.
-
Install Jest, Jest types, and a TypeScript-Jest transformer to use TypeScript with Jest at the root of the project:
-
Generate the Jest config by by running the following command at the root of the project, and following the prompts for how you want to customize your environment:
My initiating recommendations:
-
Change the default export in the Jest config file because this project uses
verbatimModuleSyntax
:Generating a Jest config through the CLI generates almost everything we’ll need! We need to configure the Jest to use the
ts-jest
package so that Jest can work on our*.ts
code. -
Change the jest config type to use the ts-jest compliant config:
-
Jest still considers ES Modules an experimental feature. To enable interop we have to manually enable
useESM
: -
Configure jest to skip
@types
when it reports which files have tests: -
Change or add the test script in the
package.json
Note: the
--experimental-vm-modules
flag to enable ES Modules in thenode:api
that jest accesses.test:coverage
: will produce an html document in./tests/coverage
that shows you which files/functions have tests.test:ci
: will not store snapshots when tests fail.
While we’re in the
package.json
add the following line to indicate to the runtime that our project should be consumed as an ES Module (I’ll repeat this step later as a reminder): -
Add jest coverage to
.gitignore
-
Create a GitHub workflow to run your tests and linting as a GitHub action
Note: actions are great to use because it will prohibit code from entering your codebase if it doesn’t pass linting/test
-
Configure the GitHub Action as follows:
Nice! We’ll write some tests later.
Note: jest will not work without our next step: setting up
ts-node
Runtime
ts-node and nodemon
nodemon is a package that will run our code on our machine and restart its instance whenever a change is made to the files. Pretty handy! But nodemon was only made to run JavaScript, so we’ll also need to use ts-node to have nodemon work with our code.
-
Install
ts-node
andnodemon
at the root of the project:Note: tslib is a TypeScript library that contains helper functions that ts-node (and Rollup plugins) depend on.
If our code was using CommonJS we’d be able to slap a
nodemon src/index.ts
script in ourpackage.json
and call it a day. Unfortunately we have to do a few more steps: -
Add the following configuration to the
tsconfig.json
:Note:
esm
requires a file type in its import statements. We usemoduleResolution
asbundler
in ourtsconfig.json
so that we don’t use file types, and the bundler can deal with it. In order for this to work withts-node
we needexperimentalSpecifierResolution
set tonode
so thatts-node
doesn’t complain that our imports don’t have file types when it executes the code on our machine. -
Add the following configuration to the
package.json
Note: Since ES Modules are also experimental for
ts-node
we need to loadts-node/esm
when our files are executed. This means we unfortunately can’t just runnodemon src/index.ts
with its out-of-the-box settings, we need to configurenodemon
run a specific command with our loaded module. -
Add a start to the
package.json
Example npm package code
Follow along with my npm starter-kit repo.
Note: see pull requests and issues for more details on my workflow.
Example Code
This package will be simple:
Types
Source
Example Tests
Building the npm package
Mental model
There are four steps to build the package:
- Emit type declaration files for all of our source code and types
- Transpile the source code to CommonJS
- Transpile the source code to ES Modules
- Generate a file of bundled TypeScript declarations for both ES Module and CommonJS
The package will be shipped to the user in JavaScript. We will also ship .d.ts
type declaration files to allow users to develop in TypeScript to understand the type and shape of the package code.
Why the build is not emitting source maps
Source maps are really for minified code that is mostly not human-readable. The purpose is so that developers can inspect modules (in the browser for example) and be directed to the (pre-minified human-readable) source code rather than the minified code, even if the minified code is being used rather than the source code.
We aren’t minifying code in this package, so we won’t need to direct users to a different human-readable source code using source maps; all of our source code should be human-readable!
Why the build is not minifying
The purpose of minifying code is to make the files smaller while keeping functionality. The process may look like:
This illustration is not a 1-to-1 on how the minification algorithm works, but the essence is that we removed a significant amount of characters that matter to the human but will be useless bytes for the runtime.
If you were, for example, shipping code that is intended to be used right in a production environment (i.e., used in the browser, or used in a project that is not bundled) then it would make sense to minify your code and save precious space in your files. In our case, users will likely consume our module in a development environment with some bundler.
Why aren’t we bundling our code into a single JavaScript file
A common pattern when creating a npm package is to emit your bundle as a single JavaScript file. Although some say you should bundle your code into a single JavaScript file simply “for cleanliness” , this has multiple problems:
- Less effective static analysis for tree shaking.
- Cannot dynamic import.
“If we are emitting our code as modules in separate files, why do we emit a single .d.ts
type declaration file?”
Because declaration files are global scoped, it doesn’t matter to the linter whether you use one file or many. We will opt for a single type declaration file that is easy to read and consume as a developer.
Another reason is to avoid breaking encapsulation which is essentially a scenario where types are bundled in the wrong declaration files.
I describe why we need to emit a single type declaration file for both our CJS and ESM builds later.
Note: we won’t face the same issues of “well now we can’t dynamically import” because you don’t dynamically import types.
Package config
These configurations will be read by the user’s runtime when they consume our package.
-
In order to have our package work for both ESM and CommonJS JavaScript, we will write a
conditional export
property in ourpackage.json
according to the TypeScript specification.Note: these settings were at one point suggested by TypeScript documentation. Based on the current TypeScript documentation it is unclear if this is still suggested. See my full explanation here
What these settings mean:
exports
: takes highest precedent in modern node and conditionally switches between CommonJS and ES Module source depending on how the user imports the package.main
: used as a fallback for old node versions looking for CommonJS that don’t supportexports
configurationmodule
: used as a fallback to when build tools usedmodule
to resolve where the ES Modules were.exports."."
: the catch-all condition for when users import the package (i.e.,from 'npm-package'
)exports."./*"
: used when users selectively import modules in import patterns like such as:
Note: subpath entry points are useful for dynamic imports/lazy-loading/code-splitting
-
(skip if completed in jest setup) Add a
type
property to thepackage.json
to declare the module type of the project (ES Module): -
Add a
files
property to thepackage.json
to determine which files will be shipped to the user: -
Add prepack and clean scripts to the
package.json
npm will use the
pack
command to ship our project, so we can take advantage of npm’s specialpre*
syntax to run scripts beforepack
runs.Note:
npm ci
creates a fresh install of our dependencies as opposed tonpm i
which will only install dependencies that aren’t currently installed.
Declarations with TSC
Our setup so far means we only need to add a single command to emit our declarations:
Bundling with Rollup.js
To use Rollup on a TypeScript project we’ll need to use the typescript plugin and the tslib library (already installed when installing ts-node!).
We’ll use the rollup-plugin-dts plugin to bundle our declaration files.
Note: if you package includes third-party modules in
node_module
I recommend using the @rollup/plugin-node-resolve plugin. If your package includes CommonJS modules I recommend using the @rollup/plugin-commonjs plugin.
-
Install Rollup and Rollup plugins at the project root:
-
Create a config file for Rollup
-
There are many options for customizing Rollup but an example for this guide would be:
-
Add a build script to the
package.json
to transpile the TypeScript into ES Module JavaScript, CommonJS JavaScript, and a bundled type declaration file:Note: I use the
--bundleConfigAsCjs
flag because the runtime can get confused about whether this config is CommonJS or ES Module.
“Why are we emitting our types twice?”
“Attempting to use a single
.d.ts
file to type both an ES module entry point and a CommonJS entry point will cause TypeScript to think only one of those entry points exists, causing compiler errors for users of the package.” - TypeScript 4.7 release notes
See my GitHub issue explaining why its unclear if this is true.
Making a demo
Let’s make a demo to observe the behaviour of our package before we publish our code.
-
From the project root you can run this command to generate a file structure for project demos:
the file structure should look like:
-
cd
into a demo (example cjs) and initiate the demo by creating apackage.json
by running the command: -
Add scripts to the
package.json
to build our package and to move the build to the demo folder:
You can then test your package and see how it would behave in a user’s environment with the command npm run pack:package
or npm start
.
Note: we’re implicitly describing the demo’s module type with file extensions (
*.mjs
/*.cjs
), but*.ts
can work with both module systems, so you need to have atype
property ofmodule
in your demo’spackage.json
to explicitly define the module type. See demo examples here
You can additionally check if your package correctly supports CommonJS and ES Modules by uploading the .tgz
file that’s created after npm run pack:package
into Are The Types Wrong.
Publish the package
It’s common to publish a npm package using the CLI or using GitHub Actions.
The most popular package registries to publish to are the npm registry or the GitHub registry.
The following instructions will run tests and publish the npm package to the GitHub registry when a release has been made for the package.
Note: release-please will automatically create a release when the version bump pull request is merged.
There are no additional steps because the GITHUB_TOKEN
is automatically supplied by your repository.
GitHub provides a guide on publishing to the npm registry, but essentially you replace registry-url
with https://registry.npmjs.org/
, create an npm access token, and add the access token to your GitHub repository secrets.
Note: users installing your package published to the GitHub registry will need to do additional steps by setting up a
.npmrc
file. I recommend publishing to the npm registry if you don’t want your users going through these extra steps.
Once you’ve made a release and published your package, you can check that your package correctly supports CommonJS and ES Modules by using Are The Types Wrong.
Thanks for reading
Keeping in mind your package requires a unique setup, I think this guide provides a detailed jumping-off point that I see as becoming exceedingly necessary4 for some developers today.
My npm starter-kit repo: https://github.com/spiltbeans/npm-package-starter-kit.
Enjoy my work? Subscribe and get notified when I write more things!
References
- Tree Shaking References5
- npm Dependencies References6
- TypeScript References7
- Jest References8
- Why Not To Bundle Your Package References9
- Package Configuration References10
- Hybrid npm Packages (ESM and CJS) References11
Footnotes
-
Interop issues with exporting as default
- https://github.com/rollup/plugins/issues/635
- https://github.com/Swatinem/rollup-plugin-dts/pull/274
- “How to Create a Hybrid NPM Module for ESM and CommonJS” by SenseDeep, see section “Single Source Base”
-
A series of articles describing why exporting as default is a horrible design pattern
- “
export default thing
is different toexport { thing as default }
” by Jake Archibald - “Avoid Export Default” by Basarat Ali Syed
- “Default Exports in JavaScript Modules Are Terrible” by Lloyd Atkinson
- “Why we have banned default exports in Javascript and you should do the same” by Kris Kaczor
- “
-
“What are ESLint and TypeScript, and how do they compare?” by typescript-eslint.io ↩
-
Examples of why developers today need detailed guides on “how to build npm packages”
- https://twitter.com/oleg008/status/1510006191296061441
- “How to make your own npm package with typescript” by Colin Diesh; re: “There is a lot of mystery around making your own npm package”
- “Apple Front End Interview Questions” by Front End Interview Handbook; re: “How do you build an npm package” question
-
Tree Shaking References
↩ -
npm Dependencies References
- “NPM: Dependencies vs Peer Dependencies” by Tapaswi
- “Specifics of npm’s package.json handling” by npmjs
- “The npm Dependency Handbook for You” by Tapas Adhikary
-
TypeScript References
↩ -
Jest References
↩ -
Why Not To Bundle Your Package References
- https://cmdcolin.github.io/posts/2022-05-27-youmaynotneedabundler
- https://stackoverflow.com/questions/36846378/what-is-the-pros-cons-of-compiling-typescript-into-single-js-file?rq=4
-
Package Configuration References
- https://www.cs.unb.ca/~bremner/teaching/cs2613/books/nodejs-api/packages/
- https://nodejs.org/api/packages.html#main-entry-point-export
-
Hybrid npm Packages (ESM and CJS) References
- “Hybrid npm packages (ESM and CommonJS)” by Dr. Axel Rauschmayer
- “How to Create a Hybrid NPM Module for ESM and CommonJS” by SenseDeep
- “Best practices for creating a modern npm package with security in mind” by Brian Clark