Written by Jack Franklin✏️
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.
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 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
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 directorylib
, 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 thesrc
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:
- 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.
- 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 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 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.
200's only ✅: Monitor failed and show GraphQL requests in production
While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.
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 GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.
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.
The post Publishing Node modules with TypeScript and ES modules appeared first on LogRocket Blog.
Top comments (0)