Jack Franklin Googler building @ChromeDevTools.

Publishing Node modules with TypeScript and ES modules

11 min read 3287

Publishing Node Modules With TypeScript And ES Modules

Editor’s note: This article was last updated by Ikeh Akinyemi on 1 May 2023 to include information about TypeScript module exports and how to import modules that were previously exported. For further reading, check out our post “How to organize code in TypeScript using modules.”

TypeScript has become a very popular language to write JavaScript in, and for 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 while 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.

Jump ahead:

Setting up our TypeScript project

The first thing we’re going to do is set up a new project. We’re going to create a basic math 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

Now we can add our first and most important dependency, TypeScript:

$ npm install --save-dev typescript

Once we have 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 command 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. Let’s go through the options now.

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 use 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.

What are Node modules?

Modules provide a convenient system for developers to structure and organize their code into smaller, encapsulated units that can easily be managed and reused across other programs. Node modules are a type of module used within the confinement of a Node.js environment.

Node modules use the CommonJS module system to write modular code, but with the advent of ECMAScript 2015 (ES6), support was added for native JavaScript modules. ES6 modules introduced the use of the import and export keywords for defining and exporting modules that are compatible with CommonJS’s require() and module.exports or exports keywords.

To use Node modules outside, particularly in web applications, we use build systems such as webpack, rollup, or babel to convert the Node modules to browser-compatible formats.

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 tree-shake 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

Later, we’ll look 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

TypeScript module exports

Let’s go over the existing approaches for exporting modules in TypeScript. Exporting modules provides flexible control over what parts of the code we want to expose to other programs.

1. Default exports

This provides the simplest approach to exporting a single value or function as the default export for a module by using the export default keywords:

export default function add(a: number, b: number): number {
  return a + b;

2. Named exports

With named exports, we can export multiple values or functions from a module using the keyword export followed by the intended value or function:

const defaultGreet = "Hello, world!";

function greet(name: string) {
  return `Hello, ${name}!`;

export {defaultGreet, greet};

3. Using export = syntax

In TypeScript, we can target module systems that allow the overwriting of the exports object. The module systems that allow overwriting are AMD and CommonJS, and we can achieve that by using the export = syntax:

interface MathFuncs {
  divide(a: number, b: number): number;
  multiply(a: number, b: number): number;

const mathFunctions: MathFuncs = {
  divide: (a, b) => a / b,
  multiply: (a, b) => a * b

export = mathFunctions;

4. Re-exports

We can export values or functions from existing modules by re-exporting them. This way, we can actually group and export as single module-related functionalities from multiple modules:

// math.ts
export function add(a: number, b: number): number {
  return a + b;

// utils.ts
export { add } from "./maths";

How to import modules

Now, let’s explore how to import modules that were exported using the knowledge we covered in the previous section.

Using  import { x } from "path"

This is used to import specific, named exports from an existing module. Let’s import the defaultGreet variable and greet function we defined previously:

import { defaultGreet, greet } from "path";

console.log(defaultGreet); // output: Hello, world!
console.log(greet("logRocket")); // output: Hello, logRocket!

Using import * as x from "path"

This is used to import all exports within a module, and have it bundled under a single namespace. This way, we can reference each export using the dot notation:

import * as greetings from "path";

console.log(greetings.defaultGreet); // output: Hello, world!
console.log(greetings.greet("logRocket")); // output: Hello, logRocket!

Using const x = require ("path")

This import syntax is commonly used to import CommonJS modules within a Node.js environment:

const mathFunctions = require("path");

let res1 = mathFunctions.divide(10, 2);
let res2 = mathFunctions.multiply(10, 2);

Using import x = require("path")

This is used to import CommonJS modules using the TypeScript syntax. It is commonly used to provide compatibility between module systems that use the exports object for exporting:

import mathFunctions = require("path");

let res1 = mathFunctions.divide(10, 2);
let res2 = mathFunctions.multiply(10, 2)

Let me quickly note that when using the import syntax within a Node environment, it will throw a SyntaxError error, because it uses the CommonJS module as its module system. As a result, it can’t recognize the ECMAScript (ES) module. We can fix this error by adding the "type": "module" config within the package.json file. Then, within the tsconfig.json file, we can set the "module": "es2015" or a higher value, like “esnext”.

Another common approach we can take towards fixing this error is to use the .mjs and .cjs file extensions for ES modules and CommonJS modules, respectively. These two module systems are partially compatible, and as a result, allow easy importing between them.

Creating our modules

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 an 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 also create src/subtract.ts:

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 {

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": [

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! 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 };

The files 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 should now be ready to publish onto npm for others to use, but first, we have two nice-to-have functionalities to add:

  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 have a better experience
  2. Currently, we’re publishing an ES module, and it would be nice to include a CommonJS version of it for compatibility across different environments, like Node

Publishing type definitions

We can provide the type information 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 that, 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:

├── 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:

├── 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.

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 because, 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": [

There’s just 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",
  "prepublishOnly": "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 while 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.


In this tutorial, we learned what Node modules are, and introduced the two popular module systems, TypeScript and ES. Then we explored the different approaches to exporting and importing modules in TypeScript. Lastly, we reviewed how to write and publish a module to the npm registry, compiling the module alongside its type definitions to be compatible across different environments.

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 to output the many formats you might need with minimal fuss.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

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 and mobile apps, recording literally everything that happens while a user interacts with your app. 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.

11 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 !

  3. It was a very useful article for me.

    Based on this article, I was able to reduce the bundle size of my work by 20KB.
    (It was a small but very meaningful change.)

    Thank you very much.

  4. You should really read the discussion on this issue: https://github.com/microsoft/TypeScript/issues/15833

    The short version is that, depending on the consumer’s build toolchain, it’s possible to wind up importing both the ESM and CJS versions of the library *at the same time*, if you publish both like this. The latest advice from that issue is to only publish CJS to NPM for now, unless the “module working group” figures out a way to ensure loaders only load one or the other.

    1. It’s a nonsense. Most mainstream packages bundle both CJS and ESM and are perfectly fine. And the only person in that thread who suggested it’s dangerous was You.

      1. Wes Wigham, a TS team member, says

        > Attempting to ship esm “side-by-side” is just going to create runtime confusion as you have the esm version and the cjs version of your package both being included via different means

        Do you have examples of “mainstream packages” that ship both types? I would genuinely like to follow best practices and it’s always good to have a well-tested model to follow. (Angular provides both via a complex series of post-install hooks, which sounds like a terrible idea for small general-purpose libraries.)

  5. Hi..This was a great help. Although, I have a question. Say, I used some dependencies in my add.ts or subtract.ts. How can I ship the complete package along wiyh the dependencies code.

Leave a Reply