Publishing TypeScript Modules to NPM

   JavaScript

TypeScript is becoming increasingly popular as more developers find it useful. Not only can you get strong typing support, but you can also use newer spec syntax. The former is one of the reasons we chose TypeScript in our Gimbal project, as typing support helps a dynamic team by showing errors early on and documenting what code should be. In this article, we will be discussing a generic TypeScript project that we will be publishing to the npm repository with an optional binary that can be executed in the command line.

Project Structure

The structure of any TypeScript project is key and can be easily configured. It’s common to place your TypeScript files in the src directory and have them compiled into the lib (can be configured to be dist) directory. By default, your custom types, when abstracted out of the individual files where they are used, would go into the typings directory and have the file extension of d.ts. There is nothing that says the directories must be named this, it’s just a common convention that has been adopted as a best practice across the industry.

A sample structure would be:

/
  lib/
    index.js
  src/
    index.ts
  typings/
    index.d.ts
  package.json
  tsconfig.json

You don’t need to create the lib directory. When you compile your TypeScript, this will be created for you in the above location.

Dependencies

Before we begin using TypeScript and compiling, we need to install some dependencies. You’ll only need three modules that should be installed as devDependencies so we’ll use the --save-dev option when installing them:

npm install --save-dev typescript ts-node @types/node

You’ll also need to tell npm which files to publish as we don’t want to publish the raw source, so that what is installed for users is as minimal as it can be. Notice that users can still access the full source code in the module’s repository. Npm will always publish some files like the README and LICENSE files (see the list here), so all you need to do is to add the lib directory to the files array in your package.json:

"files": [
  "lib"
]

I also tend to add two scripts to package.json:

"scripts": {
  "build": "tsc",
  "build:check": "tsc --noEmit",
}

The build script is what you would use when preparing to publish to npm. The build:check can be used to run a one-off test to see if your build would succeed without creating the JavaScript files in the lib directory.

tsconfig.json

TypeScript expects the tsconfig.json to be in the root of your project as shown in the sample structure. This file tells TypeScript where your source files are, where to compile to, where your typings are, and what kind of module resolution in the compiled JavaScript should be used, among other things. A simple example would be:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "lib": ["es6", "es2015"],
    "types": ["node"],
    "declaration": true,
    "outDir": "lib",
    "rootDir": "src",
    "strict": true,
  },
  "exclude": [
    "lib",
    "node_modules",
    "src/**/*.spec.ts"
  ],
  "include": [
    "src"
  ]
}

There are five options that are important and need to be pointed out:

  • outDir – This is the name of the directory of the compiled JavaScript. The compiled structure of this directory will be the same as the structure of the rootDir.
  • rootDir – The directory where the TypeScript source is.
  • types – An array of typing names to always load. In this example, it will use the @types/node module we installed before.
  • exclude – An array of glob paths to exclude from the TypeScript compilation.
  • include – An array of glob paths to include in the TypeScript compilation.

For more information on tsconfig.json, please see the TypeScript documentation. For more information on the options that can go into the compilerOptions object, see this TypeScript documentation including default values.

Adding a bin

If your project needs a binary executable that can be executed from the command line, you’ll need to add the bin directory to the exclude array in your tsconfig.json and add the bin config to your package.json. The bin directory will not be compiled with TypeScript so all files in there need to be in plain JavaScript.

An example of the bin config within your package.json is:

"bin": {
  "my-cli": "./bin/index.js"
},

Here is an example of the bin/index.js file:

#!/usr/bin/env node

const minimist = require('minimist');
const argv = minimist(process.argv.slice(2));

require('../lib')(argv);

We are using the require builtin function here since we are in a JavaScript file that has to work within Node.js. This binary will execute the compiled JavaScript; we execute it passing in the arguments from the command line parsed by minimist. This allows your project to also be executed programmatically, not just from the command line. Minimist is just one of the command line parsers but is minimal. If you need something more advanced you may want to check out commander or yargs.

The last step is to add the bin directory to the files array in package.json telling npm to publish it as well:

"files": [
  "bin",
  "lib"
]

Testing the bin

Now that you have your bin created, it’s time to test and debug using the bin. This is where the ts-node module comes in handy as you’ll need to execute the code while compilingTypeScript on the fly. I normally set up three start scripts in package.json, as follows:

"start": "node -r ts-node/register bin/index.js",
"start:break": "node --inspect-brk -r ts-node/register bin/index.js",
"start:debug": "node --inspect -r ts-node/register bin/index.js"

Each of these scripts do the same thing: they will compile your raw TypeScript on the fly using ts-node. The difference is whether you want to debug as the source executes or not. The start script will run your TypeScript without any debugging (other than console.log()s of course). The start:break and start:debug scripts will open a debugging port that allows you to connect an external debugger, such as Google Chrome or an editor. The difference between these two is start:break will not execute your TypeScript until you have attached an external debugger and resume execution, while start:debug will continue to run your source but you can still attach a debugger until the process ends. You’ll likely want to use start:break so you can attach the debugger and set any breakpoints unless you are working on an async operation, like a web server, that you want to get running.

You would execute one of these on the command line like this:

npm start

// or to pass arguments:
npm run start:break -- --foo bar

The second line passes all the options to the right of — to your node script since npm has its own options that could get used before your node script would get used. Your src/index.ts file would look like:

interface Arguments {
  foo: string;
}

const mymodule = (args: Arguments): void => {
  const { foo } = args;

  // ...
};

export default mymodule;

If you are using VSCode, you can use a launch config that looks very similar to the start script above and allows you to use VSCode’s built in debugging:

{
  "type": "node",
  "request": "launch",
  "name": "Test Bin",
  "runtimeArgs": [
    "-r",
    "ts-node/register"
  ],
  "args": [
    "${workspaceFolder}/bin/index.js",
    "--foo",
    "bar"
  ],
  "skipFiles": [
    "/**/*.js"
  ]
}

You can also use npm link along with the build:watch script in order to use your bin globally as if you installed it from a repository. This uses the compiled code which is why the build:watch script can be used to rebuild automatically.

Publishing to NPM

The first thing to notice is that, by default, npm attempts to publish as a private package. Unless you pay for and need this feature, you’ll have to set it to public. Add the following to your package.json:

"publishConfig": {
  "access": "public"
},

Now, when you execute npm publish, it will use this config and set the access to public. You can use npm publish --access publish if you do not wish to have this in your package.json, just make sure you don’t forget it (or include it as a CI step). The world will not end if you forget, but the publish will fail and you may have a commit and tag that you’ll have to clean up.

The flow to publish is basically:

npm version patch
npm run build
npm publish

While you can do this all locally, I would always recommend setting up a CI/CD pipeline to automate this. Then, all you would be responsible for is executing the npm version patch command, and once you push the tag up to the remote git repository via executing git push --follow-tags, your CI/CD will do the building and publishing for you. An example CircleCI .circleci/config.yml file looks like:

version: 2.1

jobs:
  publish:
    docker:
      - image: circleci/node:10
    steps:
      - checkout
      - run:
        name: Setup .npmrc credentials
        command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
      - run: npm ci
      - run: npm run build
      - run: npm publish

workflows:
  version: 2
  publish:
    jobs:
      - publish:
        filters:
          branches:
            ignore: /.*/
          tags:
            only: /^v.*/

Conclusion

TypeScript brings a lot of protection with the strong typing you can use. Many of today’s editors also have support for TypeScript showing you in-line hints and errors. In a world where anyone can submit a pull request to fix a bug or add a new feature, TypeScript is a natural fit as it can help catch bugs and increase developer productivity.


This website uses cookies

These cookies are used to collect information about how you interact with our website and allow us to remember you. We use this information in order to improve and customize your browsing experience, and for analytics and metrics about our visitors both on this website and other media. To find out more about the cookies we use, see our Privacy Policy.

Please consent to the use of cookies before continuing to browse our site.

Like What You See?

Got any questions?


>