How to make a modern npm package
Tags: javascript, webdev, beginners
Published: 2022-09-11 12:59:30

I've been working on an npm package for a while now, in an age of ESM and CJS going hand in hand, it is not enough that you add just one of these supports to your npm package.

Now, I will show you my work today, how to initialize an npm package elegantly.

## Set up

At first, create `package.json`

```shell
pnpm init
```

pnpm will automatically generate the necessary information.

Then, add some code:

```js
// src/math.js
export const increment = value => ++value;

// src/index.js
import {increment} from "./math";
export default increment(0);
```

Now we have a simple ESM package. As a flag for the npm package, we need to specify what we are exporting. So I add the following line to `package.json`:

```js
"exports": {
  "import": "./src/index.js",
}
```

## Bundler

As I mentioned before, it is not enough to provide only ESM or CJS support for the package, but you don't want to write two sets of code by hand that do the same thing, so you need a bundler that can transform TypeScript to ESM and CJS, I choose [esbuild](https://esbuild.github.io/), because vite also choose it. 

It is easy to install:

```shell
pnpm install esbuild -D
```

Esbuild has a very easy-to-use CLI, we just need to add some package script in your `package.json`:

```js
"scripts": {
  "build": "npm run build:esm && npm run build:cjs",
  "build:esm": "esbuild --bundle src/index.js --format=esm --outfile=dist/esm.js",
  "build:cjs": "esbuild --bundle src/index.js --format=cjs --outfile=dist/cjs.js",
}
```

Try to run `pnpm run build`, you can see `esm.js` and `cjs.js` in dist folder, so you can change the `package.json`:

```js
"exports": {
  "import": "./dist/esm.js",
  "require": "./dist/cjs.js"
}
```

Esbuild has TypeScript support out of the box, so we do not need to configure TypeScript, just rename your `.js` file to `.ts`, and change the package script:

```js
"scripts": {
  "build": "npm run build:esm && npm run build:cjs",
  "build:esm": "esbuild --bundle src/index.ts --format=esm --outfile=dist/esm.js",
  "build:cjs": "esbuild --bundle src/index.ts --format=cjs --outfile=dist/cjs.js",
}
```

## Types

Adding a type definition to a package makes the package easier to use, although esbuild does not have built-in support for type definitions, we can implement them via plugins:

```shell
pnpm add esbuild-plugin-d.ts -D
```

Esbuild cli does not support using plugins, so we should  use esbuild's JavaScript API to build our package.

create `build.js`:

```js
// build.js

const esbuild = require("esbuild");
const { dtsPlugin } = require("esbuild-plugin-d.ts");

const option = {
    bundle: true,
    color: true,
    logLevel: "info",
    sourcemap: true,
    entryPoints: ["./src/index.ts"],
    minify: true,
}

async function run() {
    await esbuild
        .build({
            format: "esm",
            outdir: "dist",
            splitting: true,
            plugins: [dtsPlugin()],
            ...option
        })
        .catch(() => process.exit(1))

    await esbuild
        .build({
            format: "cjs",
            outfile: "./dist/cjs.js",
            ...option
        })
        .catch(() => process.exit(1))
}

run()
```

Then edit `package.json`

```js
"scripts": {
  "build": "node build",
},
```

Run `pnpm run build` again and you will see that `index.d.ts` has been successfully generated.

Now, we add the location of d.ts to package.json, and the type definition is done:

```js
"types": "./dist/index.d.ts"
```

By now, you have a fully featured project, it is enough for building a simple package, but for a big package, we need **unit test** and **lint**.

## Unit test

I choose [jest](https://jestjs.io/) for unit test library, it is powerful and easy to use.

We need to install some dev dependency:

```shell
pnpm add -D @jest/global jest ts-jest typescript
```

Then, create a `jest.config.js`:

```js
// jest.config.js
module.exports = {
    preset: 'ts-jest',
    transform: {
        '^.+\\.ts?$': 'ts-jest',
    },
};
```

Jest is ready to go, let's write a unit test:

```ts
import {increment} from "../src/math";
import {describe, expect, test} from "@jest/globals";

describe("math module", () => {
  test("0 increment equal 1", () => {
    expect(increment(0)).toBe(1);
  });
});
```

Add a package script:

```js
"scripts": {
  "test": "jest"
},
```

Run `pnpm test` to launch jest.

## Lint

It seems we have no choice but to use [eslint](https://eslint.org/):

```shell
pnpm create @eslint/config
```

Manually select your feature, then add a script to your `package.json`:

```js
"scripts": {
  "lint": "eslint --ext .js,.jsx,.ts,.tsx --fix -c .eslintrc.js",
},
```

## At last

Congratulations, we have learned how to initialize an npm package elegantly. If you have a comment, drop them anyway.

If you don't want to build from scratch, you can simply use my [template](https://github.com/AkaraChen/package-starter).