DEV Community

AkaraChen
AkaraChen

Posted on • Edited on

How to make a modern npm package

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Then edit package.json

"scripts": {
  "build": "node build",
},
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then, create a jest.config.js:

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

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

Add a package script:

"scripts": {
  "test": "jest"
},
Enter fullscreen mode Exit fullscreen mode

Run pnpm test to launch jest.

Lint

It seems we have no choice but to use eslint:

pnpm create @eslint/config
Enter fullscreen mode Exit fullscreen mode

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

"scripts": {
  "lint": "eslint --ext .js,.jsx,.ts,.tsx --fix -c .eslintrc.js",
},
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)