How TypeScript over babel greatly simplifies creating libraries

Published 12/27/2019

Creating a NPM library (or project in general) sounds simple at first but once you think of supporting both node and the browser and then start thinking about ES5, ES6 etc. it can become quite a daunting task. All I want is write code using the latest features and transpile it down to ES5.

For long I was using babel for transpiling, and I don't know about you, but it is just way too complex for my taste.

Before you know it, your package.json is filled with @babel/runtime, @babel/cli, @babel/core, @babel/plugin-transform-runtime, @babel/preset-env and maybe more if you want to use recent ES features. Like, using rest and spread requires you to additionally install @babel/plugin-proposal-object-rest-spread 🤷

And worst of all, you kind of have to figure this out on your own or following (outdated) blog posts. Going though the transpiled code to make sure that stuff is actually being transpiled properly. Learning the in's and out's of ecma script proposal stages and so forth. Finally, you think you are all set and use [1, 2].includes(1) only to hear complaints from some users that the site is crashing. (More on this later)

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

TypeScript to the rescue

With TypeScript all you need is one file tsconfig.json to handle all of the JS ecosystem madness, well most of it at least.

Sure, TypeScript is also something that you have to learn, and it doesn't come without its very own set of challenges, but let me guide you through it and I'm sure you will love it! It has so much more to offer than just transpiling your code...

So let's go ahead and create a little library.

I am not here to bash babel or anything, I know them providing all these different plugins sure is useful for many projects. But then there are so many other projects where you don't really need this flexibility. As always, judge for yourself which is the way to go in your particular use case.

Preparations

First let's create a new project, initialize the package, install typescript and create an empty config file.

mkdir my-lib
cd my-lib
npm init --yes
npm install typescript --save-dev
touch tsconfig.json

Alright, next let's create a typescript file so we can test the output.

mkdir src
cd src
touch index.ts

Go ahead and open the project in your favorite code editor (I recommend vs code since it already comes with full TS support).

// src/index.ts

export function scream(text) {
  const transformedText = text.toUpperCase()
  return `${transformedText}!!!!`
}

Relatively straight forward, take the input and scream it back out again.

Let's add a script to compile the code in package.json under scripts

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "compile": "tsc"
},

tsc stands for typescript compiler and is available since we installed the typscript dependency.

Finally, let's create the configurations inside tsconfig.json

{
  "compilerOptions": {
      "outDir": "./dist"
  },
  "include": [
      "src/**/*"
  ]
}

This simply tells TS to compile everything in the "src" folder and output the compiled files in a "dist" folder.

We can now run npm run compile to compile this code and we get the following output in dist/index.js:

"use strict";
exports.__esModule = true;
function scream(text) {
    var transformedText = text.toUpperCase();
    return transformedText + "!!!!";
}
exports.scream = scream;

Typescript transpiles it down all the way to ES3 and uses commonJS as the module resolution.

Note that you can use "outFile" instead of "outDir" to compile everything down to a single file.

There are a lot of tweaks that we can do here so let's explore some common compiler options.

target & module compiler options

{
  "compilerOptions": {
      "outDir": "./dist",
      "target": "ES5",
      "module": "CommonJS",
  },
  "include": [
      "src/**/*"
  ]
}

First I don't really want to go all the way down to ES3, ES5 is already enough. We can define this using the "target" option. Next, I want to be explicit about the module resolution so it's easy to see that we indeed use CommonJS.

If you are not familiar with the module resolution, try setting it to "ES2015". This would now compile the code to ES5, however use ES modules to import/export files

export function scream(text) {
    var transformedText = text.toUpperCase();
    return transformedText + "!!!!";
}

But let's revert that again, so people can use it in Node.

Enabling strict mode

I really recommend you to get your hands dirty with TypeScript and not only use it for transpiling, but especially as a compiler. A good way of doing this is enforcing types by enabling "strict" mode.

{
  "compilerOptions": {
      "outDir": "./dist",
      "target": "ES5",
      "module": "CommonJS",
      "strict": true
  },
  "include": [
      "src/**/*"
  ]
}

If you are using VSCode you should already see some red wiggly lines in index.ts, but go ahead and try compiling your code again using npm run compile.

You should get an error saying: "src/index.ts:1:24 - error TS7006: Parameter 'text' implicitly has an 'any' type."

To fix it, let's head over to index.ts and properly type it

export function scream(text: string) {
  const transformedText = text.toUpperCase()
  return `${transformedText}!!!!`
}

This leads to fantastic developer experience because of the powerful intellisense and early error & bug catching.

Declaration files

Since we transpile the code to JavaScript, we unfortunately lose all of the type information (for intellisense) again once we import the library somewhere else. To migitate that, Typescript allows us to emit so called declaration files. We simply have to instruct TS to do so

{
  "compilerOptions": {
      "outDir": "./dist",
      "target": "ES5",
      "module": "CommonJS",
      "strict": true,
      "declaration": true
  },
  "include": [
      "src/**/*"
  ]
}

This will now output ".d.ts" files in the dist folder during compilation.

Absolute imports

This one is probably not needed for a simple library but it's good to know. You can set the "src" folder to be the base url so you don't have to write things like import something from '../../../something.

{
  "compilerOptions": {
      "outDir": "./dist",
      "target": "ES5",
      "module": "CommonJS",
      "strict": true,
      "declaration": true,
      "baseUrl": "./src"
  },
  "include": [
      "src/**/*"
  ]
}

Say you have the file src/services/something, you can now simply do import something from 'services/something'.

lib

Remember when I was mentioning this in the beginning "Finally, you think you are all set and use [1, 2].includes(1) only to hear complaints from some users that the site is crashing". So how does TypeScript save us from this?

Well, just try adding that code into the "scream" method:

export function scream(text: string) {
  [1, 2].includes(1)
  const transformedText = text.toUpperCase()
  return `${transformedText}!!!!`
}

This now gets us the error Property 'includes' does not exist on type 'number[]'.ts(2339) and that is so so great.

Think about it!

We are targetting ES5 in the tsconfig.json, but "Array.prototype.includes" is an ES2016 (ES7) feature! TypeScript, by default, let's you know there is something missing in your setup. If you go ahead and change the target to "ES2016", your code can compile just fine again. But that's not what we want...

By default Typescript doesn't include these so called polyfills, just like babel. There are just too many ways one can implement them.

A simple way to emulate a ES2015/ES6 environment is to use babel-polyfill. (But be aware of what babel-polyfill does NOT include).

So with the polyfills in place, we can now use the "lib" option to tell TypeScript that we've taken care of this dilemma and to trust us on this.

{
  "compilerOptions": {
      "outDir": "./dist",
      "lib": ["ES2018"],
      "target": "ES5",
      "module": "CommonJS",
      "strict": true,
      "declaration": true,
      "baseUrl": "./src"
  },
  "include": [
      "src/**/*"
  ]
}

So we still target ES5, but are now also allowed to write ES2018 code.

So much more

There are some more options you can explore to customize and improve your TS experience: https://www.typescriptlang.org/docs/handbook/compiler-options.html, but the current options should be enough for many projects already.

Here is a gist you can save for future references.