How to build, test, and publish a TypeScript npm package in 2022

In this article, we will build and publish an NPM package from scratch using TypeScript and Jest for testing.

We will initiate the project, set up TypeScript, write tests with Jest, and publish it to NPM.

Our project

Our simple library is called digx. It allows "digging" values from nested objects by path (it's similar to lodash get).

For example,

const source = { my: { nested: [1, 2, 3] } }
digx(source, "my.nested[1]") //=> 2

For the sake of this article, it's not that important what it does as long as it's simple and testable.

The npm module can be found here. The GitHub repository is here.

Initiate the project

Let's start by creating an empty directory and initiating it.

mkdir digx
cd digx
npm init --yes

The npm init --yes command will create a package.json file for you and fill it with some defaults (which you might want to update).

And let's also set up a git repo in the same folder.

git init
echo "node_modules" >> .gitignore
echo "dist" >> .gitignore
git add .
git commit -m "initial"

Building the library

We will be using TypeScript. Let's install it.

npm i -D typescript

Let's create a tsconfig.json file with the following configuration:

{
  "files": ["src/index.ts"],
  "compilerOptions": {
    "target": "es2015",
    "module": "es2015",
    "declaration": true,
    "outDir": "./dist",
    "noEmit": false,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  }
}

The most important settings are these:

  1. Our main file is going to live in the src folder, hence "files": ["src/index.ts"]
  2. "target": "es2015" to make sure we support modern platforms and don't carry around unnecessary shims
  3. "module": "es2015". Our module will be a standard EC module (the default is CommonJS). Modern browsers have no problems with them; even Node has supported them since version 13.
  4. "declaration": true - because we want to emit the d.ts declaration files. Our TypeScript users will require those.

Most of the other stuff is just various optional TypeScript checks, which I prefer to be turned on.

Open the package.json, and update the "scripts" section:

"scripts": {
  "build": "tsc"
}

Now we can run the build with npm run build... Which fails because we don't have any code to build yet.

But we will start from the other end.

Adding some tests

As responsible grown-ups, we are going to start with the tests. We will use jest because it's simple and awesome.

npm i -D jest @types/jest ts-jest

The ts-jest package is required for Jest to understand TypeScript. An alternative is to use babel, which will require more configuration and additional modules. We'll keep things simple.

Init the jest configuration file with

./node_modules/.bin/jest --init

Just press enter on each question. Defaults are fine.

It will create the jest.config.js file with some defaults and add the "test": "jest" script into package.json.

Open the jest.config.js, find the line starting with preset, and update it like this:

{
  // ...
  preset: "ts-jest",
  // ...
}

Finally, create the src directory and our test file src/digx.test.ts and fill it:

import dg from "./index";

test("works with a shallow object", () => {
  expect(dg({ param: 1 }, "param")).toBe(1);
});

test("works with a shallow array", () => {
  expect(dg([1, 2, 3], "[2]")).toBe(3);
});

test("works with a shallow array when shouldThrow is true", () => {
  expect(dg([1, 2, 3], "[2]", true)).toBe(3);
});

test("works with a nested object", () => {
  const source = { param: [{}, { test: "A" }] };
  expect(dg(source, "param[1].test")).toBe("A");
});

test("returns undefined when source is null", () => {
  expect(dg(null, "param[1].test")).toBeUndefined();
});

test("returns undefined when path is wrong", () => {
  expect(dg({ param: [] }, "param[1].test")).toBeUndefined();
});

test("throws an exception when path is wrong and shouldThrow is true", () => {
  expect(() => dg({ param: [] }, "param[1].test", true)).toThrow();
});

test("works tranparently with Sets and Maps", () => {
  const source = new Map([
    ["param", new Set()],
    ["innerSet", new Set([new Map(), new Map([["innerKey", "value"]])])],
  ]);
  expect(dg(source, "innerSet[1].innerKey")).toBe("value");
});

Those unit tests give a good idea of what we are building.

Our module exports a single function, digx. It accepts any object, the string path parameter, and an optional shouldThrow, which makes it throw an exception in case the nested structure of the source object doesn't allow the provided path.

The nested structures can be objects and arrays, but also Maps and Sets.

Run the tests with npm t; of course, they fail, as they should.

Now open the src/index.ts file and copy there:

export default dig;

/**
 * A dig function that takes any object with a nested structure and a path,
 * and returns the value under that path or undefined when no value is found.
 *
 * @param {any}     source - A nested objects.
 * @param {string}  path - A path string, for example `my[1].test.field`
 * @param {boolean} [shouldThrow=false] - Optionally throw an exception when nothing found
 *
 */
function dig(source: any, path: string, shouldThrow: boolean = false) {
  if (source === null || source === undefined) {
    return undefined;
  }

  // split path: "param[3].test" => ["param", 3, "test"]
  const parts = splitPath(path);

  return parts.reduce((acc, el) => {
    if (acc === undefined) {
      if (shouldThrow) {
        throw new Error(`Could not dig the value using path: ${path}`);
      } else {
        return undefined;
      }
    }

    if (isNum(el)) {
      // array getter [3]
      const arrIndex = parseInt(el);
      if (acc instanceof Set) {
        return Array.from(acc)[arrIndex];
      } else {
        return acc[arrIndex];
      }
    } else {
      // object getter
      if (acc instanceof Map) {
        return acc.get(el);
      } else {
        return acc[el];
      }
    }
  }, source);
}

const ALL_DIGITS_REGEX = /^\d+$/;

function isNum(str: string) {
  return str.match(ALL_DIGITS_REGEX);
}

const PATH_SPLIT_REGEX = /\.|\]|\[/;

function splitPath(str: string) {
  return (
    str
      .split(PATH_SPLIT_REGEX)
      // remove empty strings
      .filter((x) => !!x)
  );
}

This implementation could be better, but what is important for us is that the tests are passing now. Try it yourself with npm t.

Now, if we run npm run build, we should see the dist directory with two files, index.js and index.d.ts.

Now we are ready to publish.

Publishing the npm package

Sign up at npm if you haven't yet.

Then log in through your terminal with npm login.

We are just a step away from publishing our shiny new package. Still, there are a couple of things to take care of.

First, let's ensure we have the correct metadata in our package.json.

  1. Make sure the main property is set to our built file "main": "dist/index.js".
  2. Add the "types": "dist/index.d.ts" for our TypeScript users.
  3. Because our library is going to be used as an ESM module, we also need to specify "type": "module".
  4. The name and description should be filled in as well.

Next, we should take care of the files we're willing to publish. I don't feel like publishing any config files nor the source and test files.

One thing we could do is to use the .npmignore and list all the files we DON'T want to publish. I'd much prefer to have a "white list" instead - so let's use the files field in the package.json to specify the files we WANT to include.

{
  // ...
  "files": ["dist", "LICENSE", "README.md", "package.json"],
  // ...
}

Finally, we are ready to publish our package.

Run,

npm publish --dry-run

And make sure only the required files are included.

When we are ready, we can finally run,

npm publish

Testing it out

Let's create an entirely new project and install our module.

npm install --save digx

And now, let's write a simple program to test it out.

import dg from "digx"

console.log(dg({ test: [1, 2, 3] }, "test[0]"))

Those are our types, that's nice!

DIGX types are available out-of-the-box

Now run it with node index.js, and you should see 1 printed on the screen.

Conclusion

We created and published a simple npm package from scratch.

Our library provides an ESM module, types for TypeScript, and is covered with tests using jest.

You would probably agree that it wasn't too hard.