Creating CommonJS-based npm packages via TypeScript

[2020-04-16] dev, javascript, typescript
(Ad, please don’t block)

This blog post describes how to use TypeScript to create npm packages with CommonJS modules. All the artifacts that are shown can be downloaded via the GitHub repository ts-demo-npm-cjs (I deliberately have not published it to npm).

Required knowledge: You should be familiar with how npm packages work.

Limitations  

We are using what TypeScript currently supports best:

  • All TypeScript code is compiled to CommonJS modules with the filename extension .js.
  • We only import CommonJS modules.

Especially on Node.js, TypeScript currently doesn’t really support ECMAScript modules and filename extensions other than .js.

The repository ts-demo-npm-cjs  

This is how the repository ts-demo-npm-cjs is structured:

ts-demo-npm-cjs/
  .gitignore
  .npmignore
  dist/   (created on demand)
  package.json
  ts/
    src/
      index.ts
    test/
      index_test.ts
  tsconfig.json

Apart from the package.jsonfor the package, the repository contains:

  • index.ts: the actual code of the package
  • index_test.ts: a test for index.ts
  • tsconfig.json: configuration data for the TypeScript compiler

package.json contains scripts for compiling:

  • Source: directory ts/ (TypeScript code)
  • Target: directory dist/ (CommonJS modules; the directory doesn’t yet exist in the repository)

This is where the compilation results for the two TypeScript files are put:

ts/src/index.ts       --> dist/src/index.js
ts/test/index_test.ts --> dist/test/index_test.js

.gitignore  

This file lists the directories that we don’t want to check into git:

node_modules/
dist/

Explanations:

  • node_modules/ is set up via npm install.
  • The files in dist/ are created by the TypeScript compiler (more on that later).

.npmignore  

When it comes to which files should and should not be uploaded to the npm registry, we have different needs than we did for git. Therefore, in addition to .gitignore, we also need the file .npmignore:

ts/

The two differences are:

  • We want to upload the results of compiling TypeScript to JavaScript (directory dist/).
  • We don’t want to upload the TypeScript source files (directory ts/).

Note that npm ignores the directory node_modules/ by default.

package.json  

package.json looks like this:

{
  ···
  "type": "commonjs",
  "main": "./dist/src/index.js",
  "types": "./dist/src/index.d.ts",
  "scripts": {
    "clean": "shx rm -rf dist/*",
    "build": "tsc",
    "watch": "tsc --watch",
    "test": "mocha --ui qunit",
    "testall": "mocha --ui qunit dist/test",
    "prepack": "npm run clean && npm run build"
  },
  "// devDependencies": {
    "@types/node": "Needed for unit test assertions (assert.equal() etc.)",
    "shx": "Needed for development-time package.json scripts"
  },
  "devDependencies": {
    "@types/lodash": "···",
    "@types/mocha": "···",
    "@types/node": "···",
    "mocha": "···",
    "shx": "···"
  },
  "dependencies": {
    "lodash": "···"
  }
}

Let’s take a look at the properties:

  • type: The value "commonjs" means that .js files are interpreted as CommonJS modules.
  • main: When using a bare import with just the name of the package, this is the module that will be imported.
  • types points to the bundled type declaration file.

The next two subsections cover the remaining properties.

Scripts  

Property scripts defines various commands for building that can be invoked via npm run clean (etc.):

  • clean: Use the cross-platform package shx to delete the compilation results. shx provides a variety of shell commands with the benefit of not needing a separate package for each command we may want to use.
  • build, watch: Use the TypeScript compiler tsc to compile the TypeScript files according to tsconfig.json. tsc must be installed globally or locally (then package typescript would be a dev dependency).
  • test, testall: Use the unit test framework Mocha to run one test or all tests.
  • prepack: This script is run run before a tarball is packed (due to npm pack, npm publish, and when installing dependencies from git).

Note that when we are using an IDE, we don’t need the scripts build and watch because we can let the IDE build the artifacts. But they are needed for the script prepack.

More information  

dependencies vs. devDependencies  

dependencies should only contain the packages that are needed when importing a package. That excludes packages that are needed for running tests etc.

Packages whose names start with @types/ provide TypeScript type definitions (which enable auto-completion even for JavaScript) for packages that don’t include them. Are these normal dependencies or dev dependencies? It depends:

  • If the type definitions of our package refer to type definitions in another package, that package is a normal dependency.

  • Otherwise, the package is only needed during development time and a dev dependency.

tsconfig.json  

{
  "compilerOptions": {
    "rootDir": "ts",
    "outDir": "dist",
    "target": "es2019",
    "lib": [
      "es2019"
    ],
    "module": "commonjs",
    "esModuleInterop": true,
    "strict": true,
    "declaration": true,
    "sourceMap": true
  }
}
  • rootDir: Where are our TypeScript files located?
  • outDir: Where should the compilation results be put?
  • target: What is the targeted ECMAScript version? Features that the targeted version does not support will be compiled to features that it does support. Therefore, if we target ES3, many features with be compiled.
  • lib: What platform features should TypeScript be aware of? Possibilities includes the ECMAScript standard library and the DOM of browsers. The Node.js API is supported differently, via the package @types/node.
  • module: Specifies the format of the compilation output.

The remaining options are explained by the official documentation for tsconfig.json.

TypeScript code  

index.ts  

This file provides the actual functionality of the package:

import endsWith from 'lodash/endsWith';

export function removeSuffix(str: string, suffix: string) {
  if (!endsWith(str, suffix)) {
    throw new Error(`${JSON.stringify(suffix)} is not a suffix of ${JSON.stringify(str)}`);
  }
  return str.slice(0, -suffix.length);
}

It uses function endsWith() of the library Lodash. That’s why Lodash is a normal dependency – it is needed at runtime.

index_test.ts  

This file contains a unit test for index.ts:

import { strict as assert } from 'assert';
import { removeSuffix } from '../src/index';

test('removeSuffix()', () => {
  assert.equal(
    removeSuffix('myfile.txt', '.txt'),
    'myfile');
  assert.throws(() => removeSuffix('myfile.txt', 'abc'));
});

We can run the test like this:

npm t dist/test/index_test.js
  • The npm command t is an abbreviation for the npm command test.
  • test is an abbreviation for run test (which runs that script from package.json).

As you can see, we are running the compiled version of the test (in directory dist/), not the TypeScript code.

For more information on the unit test framework Mocha, see its homepage.