Jack Franklin Googler building @ChromeDevTools.

Publishing Node modules with TypeScript and ES modules

8 min read 2505

Publishing Node Modules With TypeScript And ES Modules

TypeScript has become a very popular language to write JavaScript in, and with good reason. Its typing system and compiler are able to catch a variety of bugs at compile time before your software has even run, and the additional code editor functionality makes it a very productive environment to be a developer in.

But what happens when you want to write a library or package in TypeScript, yet ship JavaScript so that your end users don’t have to manually compile your code? And how do we author using modern JavaScript features like ES modules whilst still getting all the benefits of TypeScript?

This article aims to solve all these questions and provide you with a setup that’ll let you confidently write and share TypeScript libraries with an easy experience for the consumers of your package.

Getting started

The first thing we’re going to do is set up a new project. We’re going to create a basic maths package throughout this tutorial — not one that serves any real-world purpose — because it’ll let us demonstrate all the TypeScript we need without getting sidetracked on what the package actually does.

First, create an empty directory and run npm init -y to create a new project. This will create your package.json and give you an empty project to work on:

$ mkdir maths-package
$ cd maths-package
$ npm init -y

And now we can add our first and most important dependency: TypeScript!

$ npm install --save-dev typescript

At the time of writing, the latest version of TypeScript is 3.8.

Once we’ve got TypeScript installed, we can initialize a TypeScript project by running tsc --init. tsc is short for “TypeScript Compiler” and is the command line tool for TypeScript.

To ensure you run the TypeScript compiler that we just installed locally, you should prefix the command with npx. npx is a great tool that will look for the command you gave it within your node_modules folder, so by prefixing our command, we ensure we’re using the local version and not any other global version of TypeScript that you might have installed.

$ npx tsc --init

This will create a tsconfig.json file, which is responsible for configuring our TypeScript project. You’ll see that the file has hundreds of options, most of which are commented out (TypeScript supports comments in the tsconfig.json file). I’ve cut my file down to just the enabled settings, and it looks like this:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}

We’ll need to make some changes to this config to enable us to publish our package using ES modules, so let’s go through the options now.

We made a custom demo for .
No really. Click here to check it out.

Configuring tsconfig.json options

If you’re ever looking for a comprehensive list of all possible tsconfig options, the TypeScript site has you covered with this handy reference.

Let’s start with target. This defines the level of JavaScript support in the browsers you’re going to be serving your code in. If you have to deal with an older set of browsers that might not have all the latest and greatest features, you could set this to ES2015. TypeScript will even support ES3 if you really need maximum browser coverage.

We’ll go for ES2015 here for this module, but feel free to change this accordingly. As an example, if I was building a quick side project for myself and only cared about the cutting-edge browsers, I’d quite happily set this to ES2020.

Choosing a module system

Next, we have to decide which module system we’ll use for this project. Note that this isn’t which module system we’re going to author in, but which module system TypeScript’s compiler will use when it outputs the code.

What I like to do when publishing modules is publish two versions:

  • A modern version with ES modules so that bundling tools can smartly treeshake away code that isn’t used, and so a browser that supports ES modules can simply import the files
  • A version that uses CommonJS modules (the require code you’ll be used to if you work in Node) so older build tools and Node.js environments can easily run the code

We’ll look later at how to bundle twice with different options, but for now, let’s configure TypeScript to output ES modules. We can do this by setting the module setting to ES2020.

Now your tsconfig.json file should look like this:

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ES2020",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  }
}

Writing some code

Before we can talk about bundling code, we need to write some! Let’s create two small modules that both export a function and a main entry file for our module that exports all our code.

I like to put all my TypeScript code in a src directory because that means we can point the TypeScript compiler directly at it, so I’ll create src/add.ts with the following:

export const add = (x: number, y:number):number => {
  return x + y;
}

And I’ll create src/subtract.ts, too:

export const subtract = (x: number, y:number):number => {
  return x - y;
}

And finally, src/index.ts will import all our API methods and export them again:

import { add } from './add.js'
import { subtract } from './subtract.js'
export {
  add,
  subtract
}

This means that a user can get at our functions by importing just what they need, or by getting everything:

import { add } from 'maths-package';

import * as MathsPackage from 'maths-package';

Notice that in src/index.ts my imports include file extensions. This isn’t necessary if you only want to support Node.js and build tools (such as webpack), but if you want to support browsers that support ES modules, you’ll need the file extensions.

Compiling with TypeScript

Let’s see if we can get TypeScript compiling our code. We’ll need to make a couple of tweaks to our tsconfig.json file before we can do that:

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ES2020",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./lib",
  },
  "include": [
    "./src"
  ]
}

The two changes we’ve made are:

  • compilerOptions.outDir – this tells TypeScript to compile our code into a directory. In this case, I’ve told it to name that directory lib, but you can name it whatever you’d like
  • include – this tells TypeScript which files we’d like to be included in the compilation process. In our case, all our code sits within the src directory, so I pass that in. This is why I like keeping all my TS source files in one folder — it makes the configuration really easy

Let’s give this a go and see what happens! I find when tweaking my TypeScript configuration the approach that works best for me is to tweak, compile, check the output, and tweak again. Don’t be afraid to play around with the settings and see how they impact the final result.

To compile TypeScript, we will run tsc and use the -p flag (short for “project”) to tell it where our tsconfig.json lives:

npx tsc -p tsconfig.json

If you have any type errors or configuration issues, this is where they will appear. If not, you should see nothing — but notice you have a new lib directory with files in it! TypeScript won’t merge any files together when it compiles but will convert each individual module into its JavaScript equivalent.

Let’s look at the three files it’s outputted:

// lib/add.js
export const add = (x, y) => {
    return x + y;
};

// lib/subtract.js
export const subtract = (x, y) => {
    return x - y;
};

// lib/index.js
import { add } from './add.js';
import { subtract } from './subtract.js';
export { add, subtract };

They look very similar to our input but without the type annotations we added. That’s to be expected: we authored our code in ES modules and told TypeScript to output in that form, too. If we’d used any JavaScript features newer than ES2015, TypeScript would have converted them into ES2015-friendly syntax, but in our case, we haven’t, so TypeScript largely just leaves everything alone.

This module would now be ready to publish onto npm for others to consume, but we have two problems to solve:

  1. We’re not publishing any type information in our code. This doesn’t cause breakages for our users, but it’s a missed opportunity: if we publish our types, too, then people using an editor that supports TypeScript and/or people writing their apps in TypeScript will get a nicer experience.
  2. Node doesn’t yet support ES modules out of the box. It’d be great to publish a CommonJS version, too, so Node works with no extra effort. ES module support is coming in Node 13 and beyond, but it’ll be a while before the ecosystem catches up.

Publishing type definitions

We can solve the type information issue by asking TypeScript to emit a declaration file alongside the code it writes. This file ends in .d.ts and will contain type information about our code. Think of it like source code except rather than containing types and the implementation, it only contains the types.

Let’s add "declaration": true to our tsconfig.json (in the "compilerOptions" part) and run npx tsc -p tsconfig.json again.

Top tip! I like to add a script to my package.json that does the compiling so it’s less to type:

  "scripts": {
    "tsc": "tsc -p tsconfig.json"
  }

And then I can run npm run tsc to compile my code.

You’ll now see that alongside each JavaScript file — say, add.js — there’s an equivalent add.d.ts file that looks like this:

// lib/add.d.ts
export declare const add: (x: number, y: number) => number;

So now when users consume our module, the TypeScript compiler will be able to pick up all these types.

Publishing to CommonJS

The final part of the puzzle is to also configure TypeScript to output a version of our code that uses CommonJS. We can do this by making two tsconfig.json files, one that targets ES modules and another for CommonJS. Rather than duplicate all our configuration, though, we can have the CommonJS configuration extend our default and override the modules setting.

Let’s create tsconfig-cjs.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "./lib/cjs"
  },
}

The important part is the first line, which means this configuration inherits all settings from tsconfig.json by default. This is important because you don’t want to have to sync settings between multiple JSON files. We then override the settings we need to change. I update module accordingly and then update the outDir setting to lib/cjs so that we output to a subfolder within lib.

At this point, I also update the tsc script in my package.json:

"scripts": {
  "tsc": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json"
}

And now when we run npm run tsc, we’ll compile twice, and our lib directory will look like this:

lib
├── add.d.ts
├── add.js
├── cjs
│   ├── add.d.ts
│   ├── add.js
│   ├── index.d.ts
│   ├── index.js
│   ├── subtract.d.ts
│   └── subtract.js
├── index.d.ts
├── index.js
├── subtract.d.ts
└── subtract.js

1 directory, 12 files

This is a bit untidy; let’s update our ESM output to output into lib/esm by updating the outDir option in tsconfig.json accordingly:

lib
├── cjs
│   ├── add.d.ts
│   ├── add.js
│   ├── index.d.ts
│   ├── index.js
│   ├── subtract.d.ts
│   └── subtract.js
└── esm
    ├── add.d.ts
    ├── add.js
    ├── index.d.ts
    ├── index.js
    ├── subtract.d.ts
    └── subtract.js

2 directories, 12 files

Feel free to have your own naming conventions or directory structures — this is just what I like to go with, but that doesn’t mean you have to as well!

Preparing to publish our module

We now have all the parts we need to publish our code to npm. The last step is to tell Node and our users’ preferred bundlers how to bundle our code.

The first property in package.json we need to set is main. This is what defines our primary entry point. For example, when a user writes const package = require('maths-package'), this is the file that will be loaded.

To maintain good compatibility, I like to set this to the CommonJS source since, at the time of writing, that’s what most tools expect by default. So we’ll set this to ./lib/cjs/index.js.

Next, we’ll set the module property. This is the property that should link to the ES modules version of our package. Tools that support this will be able to use this version of our package. So this should be set to ./lib/esm/index.js.

Next, we’ll add a files entry to our package.json. This is where we define all the files that should be included when we publish the module. I like to use this approach to explicitly define what files I want included in our final module when it’s pushed to npm.

This lets us keep the size of our module down — we won’t publish our src files, for example, and instead publish the lib directory. If you provide a directory in the files entry, all its files and subdirectories are included by default, so you don’t have to list them all.

Top tip! If you want to see which files will be included in your module, run npx pkgfiles to get a list.

Our package.json now has these additional three fields in it:

  "main": "./lib/cjs/index.js",
  "module": "./lib/esm/index.js",
  "files": [
    "lib/"
  ],

There’s one last step. Because we are publishing the lib directory, we need to ensure that when we run npm publish, the lib directory is up to date. The npm documentation has a section about how to do just this — and we can use the prepublishOnly script. This script will be run for us automatically when we run npm publish:

"scripts": {
  "tsc": "tsc -p tsconfig.json && tsc -p tsconfig-cjs.json",
  "prepublish": "npm run tsc"
},

Note that there is also a script called prepublish, making it slightly confusing which to choose. The npm docs mention this: prepublish is deprecated, and if you want to run code only on publish, you should use prepublishOnly.

And with that, running npm publish will run our TypeScript compiler and publish the module online! I published the package under @jackfranklin/maths-package-for-blog-post, and whilst I don’t recommend you use it, you can browse the files and have a look. I’ve also uploaded all the code into CodeSandbox so you can download it or hack with it as you please.

Conclusion

And that’s it! I hope this tutorial has shown you that getting up and running with TypeScript isn’t quite as daunting as it first appears, and with a bit of tweaking, it’s possible to get TypeScript outputting the many formats you might need with minimal fuss.

You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

    Which of these topics are you most interested in?
    ReactVueAngularNew frameworks
    Do you spend a lot of time reproducing errors in your apps?
    YesNo
    Which, if any, do you think would help you reproduce errors more effectively?
    A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
    Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

    200’s only Monitor failed and slow network requests in production

    Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/

    LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

    LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
    Jack Franklin Googler building @ChromeDevTools.

    4 Replies to “Publishing Node modules with TypeScript and ES modules”

    1. Thanks for the great article, I’ve really enjoyed it!

      Wanted to mention a possible slip: on section “Preparing to publish our module” -> “Prepublish” package.json part, inside the “scripts” object “prepublish” is used, although the article mentions using “prepublishOnly”. If I’m misunderstanding something please ignore this segment of the comment. 🙂

    2. Super simple, clear and objective tutorial, I’m not even from a webdev background and could be able to build a simple typescript based package for node and web just following this tutorial.

      Thank’s a lot !

    Leave a Reply