TypeScript and esbuild

Mar 08, 2022

How to publish an NPM package using TypeScript and esbuild.

Learning TypeScript has been on my todo list for what feels like forever now. Between the pandemic and being a working mom, it's been hard to have the energy and motivation to learn new things outside of work ๐Ÿ˜ช I've had a little bit of exposure at work dabbling in some backend stuff, but not enough to feel comfortable.

This weekend, I decided to get my feet wet a little bit more and make a simple little function. Then I figured, what the heck, why not just figure out how to also publish it to NPM. So if you're new to TypeScript, bundling with esbuild, and/or publishing a package to NPM, hopefully you'll find this helpful!

If you'd like to read up on the history of modules and bundling, Caitlin Munley has an interesting article!

You can check out the package I created over the weekend that's very similar to what we'll make in this post.

Setting up the project

  1. First, let's set up our repo by making a folder for it to live in:
mkdir my-package
  1. Inside of our newly created my-package directory, we'll want to create a package.json:
cd my-package
npm init -y

The -y flag autofills the values of your package.json with some defaults. You can go into the file and manually change them later.

  1. I also like to create a git repository and README from the get-go, too. We can initialize our project to use git, create a simple README with a "My Package" heading, and create an initial commit all from the command line.
git init
echo "# My Package" >> README.md
git add . && git commit -m "Initial commit"
  1. We'll need to create a remote git repository eventually anyway in order to publish to NPM, so might as well do it now! For this project, let's use GitHub.

Alright! We have the bare bones of our project all set up! If we open it up in our code editor, our file structure should look like this:

โ”ฃ ๐Ÿ“„ package.json
โ”— ๐Ÿ“„ README.md

Building with TypeScript

Now that we have some initial setup out of the way, it's time to jump into the โœจ code โœจ.

Configuration

  1. First, we'll need to install TypeScript as a dev dependency. Since TypeScript is only needed in development, end users who are using our package won't need to install TypeScript as a dependency for their project just to be able to use our package.
npm install --save-dev typescript
  1. Now that we've installed a dependency, we have a new package_lock.json file and a node_modules folder in our repo. Let's create a .gitignore file in the root of our project so that we don't accidentally commit and push all of those node modules.
.gitignoreTEXT
node_modules/
  1. In order to compile TypeScript into JavaScript, we also need to create a tsconfig.json file.

We can do this using the CLI:

npm install -g typescript # only if you don't already have typescript installed globally
tsc --init

OR we can manually create a tsconfig.json file in the root of our repo. We'll want our config to look like this:

tsconfig.jsonJSON
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
  • target: Compile to es2016 for browser compatibility
  • module: Use CommonJS for compatibility
  • esModuleInterop: Don't treat CommonJS and ES6 modules the same
  • forceConsistentCasingInFileNames: To enforce consistent filename casing
  • strict: Enable all strict mode family options
  • skipLibCheck: Skip type checking of declaration files
  • exclude: Don't transpile node_modules, anything in the dist/ directory, or any test files with the *.test.ts filename structure.

You can read up on the different compiler options in the TypeScript docs

Writing our code

With TypeScript configured, let's write our code! We'll keep things simple with a function that takes in 2 numbers and returns their sum.

src/index.tsTS
export function sum(num1: number, num2: number): number {
return num1 + num2;
}

Bundling with esbuild

Now that we've written our tiny function with TypeScript, how do we compile and bundle it up? Enter esbuild! esbuild claims to be 10-100x faster than current build tools (i.e. webpack, rollup, etc.) and who doesn't like faster tools?

  1. Let's start with installing esbuild.
npm install esbuild --save-dev
  1. Next, let's create a build.js file to house our code that will actually run esbuild.
build.jsJS
1const { build } = require("esbuild");
2const { dependencies, peerDependencies } = require('./package.json')
3
4const sharedConfig = {
5 entryPoints: ["src/index.ts"],
6 bundle: true,
7 minify: true,
8 external: Object.keys(dependencies).concat(Object.keys(peerDependencies)),
9};
10
11build({
12 ...sharedConfig,
13 platform: 'node', // for CJS
14 outfile: "dist/index.js",
15});
16
17build({
18 ...sharedConfig,
19 outfile: "dist/index.esm.js",
20 platform: 'neutral', // for ESM
21 format: "esm",
22});

Let's walk through what's going on here.

First thing of note is that we're actually compiling our src/index.ts file (line 5) into two different kinds of files: "index.js" (line 14) and "index.esm.js" (line 19). This is so that we can account for both browser and Node environments.

There are lots of different config options that you can read about in the esbuild docs.

  • entryPoints: The entry point for the application.
  • bundle: Bundle all input sources into a single output source instead of one output source per input.
  • minify: Minify compiled JavaScript code to reduce file size for output files.
  • external: Files to exclude from our bundle. The code example above is a way to use our package.json as the source of truth to exclude all of our dependencies and peer dependencies from being bundled. For this tiny package we're making in this post, we don't actually need this.
  • platform: Set the designed platform for your package's code (defaults to browser).
  • format: output format for the generated JavaScript files
  • outfile: filename that you want the generated JavaScript code to be written to.

Note: esbuild will automatically detect that we're using TypeScript and look for a tsconfig.json. If it finds one, any options set there will override any compiler options we passed to our esbuild configuration.

Generating type definitions

We have esbuild set up to bundle our code, but now we want to generate type definitions to allow developers using our package to be able to use types that we've written.

One way to do this is by using the npm-dts library.

npm install npm-dts --save-dev

Then we can add it to our build.js file:

build.jsJS
1const { build } = require("esbuild");
2const { dependencies, peerDependencies } = require('./package.json');
3const { Generator } = require('npm-dts');
4
5new Generator({
6 entry: 'src/index.ts',
7 output: 'dist/index.d.ts',
8}).generate();
9
10const sharedConfig = {
11 entryPoints: ["src/index.ts"],
12 bundle: true,
13 minify: true,
14 external: Object.keys(dependencies).concat(Object.keys(peerDependencies)),
15};
16
17build({
18 ...sharedConfig,
19 platform: 'node', // for CJS
20 outfile: "dist/index.js",
21});
22
23build({
24 ...sharedConfig,
25 outfile: "dist/index.esm.js",
26 platform: 'neutral', // for ESM
27 format: "esm",
28});

Compiling our code

  1. Now that our build.js file is all built out, let's add a script to our package.json.
package.jsonJSON
{
"scripts": {
"build": "node build.js",
}
}
  1. Now if we run npm run build in our terminal, we'll see a dist folder is created that contains an index.js, index.esm.js, and index.d.ts file.

  2. Since our TypeScript files from src are being compiled, bundled, and minified, we don't want to include the contents of this src folder in our NPM package. Just our new dist folder. To do this, we can add a files field to our package.json to tell NPM which files we want to include when we publish our package (some files, like package.json and README.md are automatically included). This will also help reduce the file size of our package.

package.jsonJSON
{
"files": ["dist/*"]
}
  1. To make sure NPM publishes our package correctly with the different compiled files that were generated, we need to add a few more fields:
package.jsonJSON
{
"module": "dist/index.esm.js",
"main": "dist/index.js", // tells NPM where to import modules from
"typings": "dist/index.d.ts" // tells users' code editor where to find the types
}

"typings" and "types" are interchangeable field names

  1. We typically don't want to include auto-generated files in our git source control, so let's add our new dist folder to our `.gitignore file.

Our project file structure should look something like this up to this point:

โ”ฃ ๐Ÿ“‚ node_modules
โ”ฃ ๐Ÿ“‚ dist
โ”ƒ โ”ฃ ๐Ÿ“„ index.js
โ”ƒ โ”ฃ ๐Ÿ“„ index.esm.js
โ”ƒ โ”ฃ ๐Ÿ“„ index.d.ts
โ”ฃ ๐Ÿ“‚ src
โ”ƒ โ”ฃ ๐Ÿ“„ index.ts
โ”ฃ ๐Ÿ“„ .gitignore
โ”ฃ ๐Ÿ“„ build.js
โ”ฃ ๐Ÿ“„ package_lock.json
โ”ฃ ๐Ÿ“„ package.json
โ”ฃ ๐Ÿ“„ README.md
โ”— ๐Ÿ“„ tsconfig.json

Publishing the package

Before we can publish our package, we need to make sure we have an NPM account. If you don't have, you'll need to sign up.

  1. Once signed up, we can log in from our terminal.
npm login

You can check which NPM user is logged via the npm whoami command

  1. Now that we're logged in, it's time to publish our package ๐Ÿ“ฆ
npm publish

If it gives you an issue about it being private and you're ok with your NPM package being public, add the --access=public flag.

To see what the result of npm publish will be without actually publishing yet, we can run npm publish --dry-run. This is especially handy if you want to make sure you'll be publishing the correct files and to see what the size of your package will be.

If all went well, we should see something like this in the terminal:

> npm publish
npm notice
npm notice ๐Ÿ“ฆ my-package@0.1.0
npm notice === Tarball Contents ===
npm notice 357B README.md
npm notice 231B dist/index.d.ts
npm notice 260B dist/index.esm.js
npm notice 853B dist/index.js
npm notice 1.0kB package.json
npm notice === Tarball Details ===
npm notice name: my-package
npm notice version: 0.1.0
npm notice filename: my-package-0.1.0.tgz
npm notice package size: 1.5 kB
npm notice unpacked size: 2.7 kB
npm notice shasum: 59ca63afc0fb706d534fda7b51b5fe5be60ae4d1
npm notice integrity: sha512-88tnfkqVQVTeR[...]lqKOnxL5wsH1Q==
npm notice total files: 5
npm notice
+ my-package@0.1.0

Now we can check out our newly published package on NPM! The url will be at https://npmjs.com/package/<your-package-name>. So if our package was named "my-package", our url would be "https://npmjs.com/package/my-package".

Making our package more robust

Maybe robust isn't the right word, but there are some things we can add to our package to make it less brittle and easier to maintain. In this section, we'll focus on linting, formatting, and testing.

Linting and formatting

Having some rules and consistent formatting can help us keep our code clean and look consistent across the board. This is especially helpful if you collaborate with other developers!

  1. Since we only need these kinds of tools in development, we can add them as dev dependencies to our project.
npm install --save-dev prettier tslint tslint-config-prettier
  • prettier: formatting
  • tslint: linting for TypeScript
  • tslint-config-prettier: a preset to prevent conflicts between tslint and prettier formatting rules
  1. Create a tslint.json file in the root of the project.
tslint.jsonJSON
{
"extends": ["tslint:recommended", "tslint-config-prettier"]
}
  1. Create a .prettierrc file in the root of the project. Change these rules to fit your preferences.
.prettierrcJSON
{
"printWidth": 120,
"trailingComma": "all",
"singleQuote": true
}
  1. Now that our linting and formatting rules are configured, let's add some scripts in our package.json.
package.jsonJSON
{
"scripts": {
"format": "prettier --write \"src/**/*.ts\"",
"lint": "tslint -p tsconfig.json"
}
}
  1. Now if we run our new commands, our TypeScript src files will be formatted with prettier and linted with tslint.
npm run lint
npm run format

Testing with Jest

Now that we have our package set up to have consistent linting and formatting, we can double check the code of our package with tests!

  1. Install testing libraries as dev dependencies, since we only need to run tests during development.
npm install --save-dev jest ts-jest @types/jest
  1. Create a jest.config.js file to configure Jest. For this simple project, this basic configuration should be adequate. You can check out the Jest docs for more configuration options.
jest.config.jsJS
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

We've been creating all of our configs (tslint, prettier, and Jest) as separate files instead of putting them in our package.json to keep that file smaller for when we go to publish our package. Developers using our package don't care what our configs for dev dependencies look like, so we can pull those out to their own files.

  1. Update the test script in our package.json.
package.jsonJSON
{
"scripts": {
"test": "jest --config jest.config.js"
}
}
  1. Now let's write a simple unit test! Let's create a file in src called index.test.ts. Since this is a tiny package with just one file in src, we can create a single test file (rather than creating a __tests__ folder and putting our test file in there).
src/index.test.tsTS
import { sum } from "./";
describe("sum function", () => {
it("adds 2 numbers", () => {
expect(sum(1, 2)).toBe(3);
});
});
  1. Now if we run npm test, we'll see our test results output in the terminal. Hopefully you'll see something like this!
npm test
> jest --config jest.config.js
PASS src/index.test.ts
sum
โœ“ adds 2 numbers (1 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.627 s
Ran all test suites.

Automating our checks

Now that we have linting, formatting, and tests set up to help our code be more maintainable and resilient, let's automate them to take some pressure off of ourselves. We can utilize some npm scripts to help us out:

  • prepare
  • prepublishOnly
  • preversion
  • version
  • postversion
  1. prepare runs before the package is packed and published, as well as on a local install. This is a great use case for building our code, so let's add a script to our package.json.
package.jsonJSON
{
"scripts": {
"prepare": "npm run build"
}
}
  1. prepublishOnly runs before prepare, but only if we're running npm publish. Here, we can run our linter and tests to avoid publishing any bad code.
package.jsonJSON
{
"scripts": {
"prepublishOnly": "npm run lint && npm test"
}
}
  1. preversion runs before bumping our package to a new version. Just to be extra positive we're not bumping a new version with bad code, we can run our linter and tests here, too.
package.jsonJSON
{
"scripts": {
"preversion": "npm run lint && npm test"
}
}
  1. version runs after a new package version has been bumped, before the commit is made. If our package has a git repository, like ours here, a new commit and version tag is made every time we bump the version. So this version script runs before that new commit, allowing us to sneak in some formatting and stage the formatted files to go with the commit about to be made.
package.jsonJSON
{
"scripts": {
"version": "npm run format && git add -A src"
}
}
  1. postversion runs after the commit has been made for a package version bump. Here, we can push that commit and the version tag to our git repository.
package.jsonJSON
{
"scripts": {
"postversion": "git push && git push --tags"
}
}
  1. If we've done everything up until this point, our scripts in our package.json should look like this:
package.jsonJSON
{
"scripts": {
"build": "node build.js",
"test": "jest --config jest.config.js",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "tslint -p tsconfig.json",
"prepublishOnly": "npm test && npm run lint",
"preversion": "npm test && npm run lint",
"version": "npm run format && git add -A src",
"postversion": "git push && git push --tags"
},
}

Tidying up the package.json

Now that we have all of our dependencies and scripts, let's flesh out our package.json fields to give NPM some more info to work with.

NPM needs a name and version to publish. Adding your name as the author and a description helps other developers know more about the package. Adding a repository will provide that link on your package's NPM page.

module, main, typings, and files tell NPM which files to publish. main tells NPM which file to import the modules from.

Our file should look something like this when we're done:

package.json
1{
2 "name": "my-package",
3 "version": "0.1.0",
4 "author": "Janessa Garrow",
5 "license": "MIT",
6 "description": "JavaScript function to add 2 numbers together",
7 "repository": {
8 "type": "git",
9 "url": "git+https://github.com/jgarrow/my-package.git"
10 },
11 "module": "dist/index.esm.js",
12 "main": "dist/index.js",
13 "typings": "dist/index.d.ts",
14 "files": [
15 "dist/*"
16 ],
17 "scripts": {
18 "build": "node build.js",
19 "test": "jest --config jest.config.js",
20 "format": "prettier --write \"src/**/*.ts\"",
21 "lint": "tslint -p tsconfig.json",
22 "prepublishOnly": "npm test && npm run lint",
23 "preversion": "npm test && npm run lint",
24 "version": "npm run format && git add -A src",
25 "postversion": "git push && git push --tags"
26 },
27 "devDependencies": {
28 "@types/jest": "^27.4.1",
29 "esbuild": "^0.14.25",
30 "jest": "^27.5.1",
31 "npm-dts": "^1.3.11",
32 "prettier": "^2.5.1",
33 "ts-jest": "^27.1.3",
34 "tslint": "^6.1.3",
35 "tslint-config-prettier": "^1.18.0",
36 "typescript": "^4.6.2"
37 }
38}

Bumping a new version

If you haven't already, now's a good time to commit your code and push it to your remote git repo.

git add -A && git commit -m "Setup package for npm"
git push

To bump a new version, like when we're wanting to publish a patch, we can run the following command:

npm version patch

This will kick off the scripts for our preversion, version, and postversion commands (in that order). A new git tag will be created and our code will be pushed to our remote git repo.

Then all that's left is to publish our new version on NPM!

npm publish

Conclusion

We made it! ๐ŸŽ‰ If all went well, we successfully bundled and minified our TypeScript package with esbuild and published it to NPM. We added some linting, formatting, and unit tests to our package to improve the maintainability of our package and keep our files looking clean and consistent. And we added some scripts to help automate our checks and help us make sure we don't miss a step when pushing and publishing updates.

If you found this article helpful or know some improvements I can make to this tutorial, reach out to me on Twitter! I'd love to hear your thoughts ๐Ÿ˜Š