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:
tsconfig.json
optionsThe 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.
tsconfig.json
optionsIf 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
.
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.
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:
require
code you’ll be used to if you work in Node), so older build tools and Node.js environments can easily run the codeLater, 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 } }
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.
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; }
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};
export =
syntaxIn 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;
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";
Now, let’s explore how to import modules that were exported using the knowledge we covered in the previous section.
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!
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!
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);
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.
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 { 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.
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 likeinclude
: 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 easyLet’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:
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.
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.
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": [ "lib/" ],
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 useprepublishOnly
.
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.
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.
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.
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. Start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
12 Replies to "Publishing Node modules with TypeScript and ES modules"
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. 🙂
Excellent. Thank you so much.
Thanks Man
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 !
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.
Excellent presentation. Thanks for the great instruction.
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.
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.
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.)
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.
I love this post. 😍
Really awesome write up! I went through a ton of articles on the subject and struggled with this for 2-3 days. Nothing worked until I followed your instructions step-by-step. Thank you!