How to make a modern npm package

Photo by Clay Banks on Unsplash

How to make a modern npm package

ยท

4 min read

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

pnpm init

pnpm will automatically generate the necessary information.

Then, add some code:

// 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:

"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, because vite also choose it.

It is easy to install:

pnpm install esbuild -D

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

"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:

"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:

"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:

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:

// 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

"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:

"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 for unit test library, it is powerful and easy to use.

We need to install some dev dependency:

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

Then, create a jest.config.js:

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

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

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:

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

Run pnpm test to launch jest.

Lint

It seems we have no choice but to use eslint:

pnpm create @eslint/config

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

"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.