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
- First, let's set up our repo by making a folder for it to live in:
mkdir my-package
- Inside of our newly created
my-package
directory, we'll want to create apackage.json
:
cd my-packagenpm init -y
The
-y
flag autofills the values of yourpackage.json
with some defaults. You can go into the file and manually change them later.
- 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 initecho "# My Package" >> README.mdgit add . && git commit -m "Initial commit"
- 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.
If you prefer using the GitHub CLI, you can skip to step 4 in the GitHub CLI docs
If you prefer to create your repository directly on the GitHub website, you can skip to step 7 in the GitHub docs
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
- 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
- Now that we've installed a dependency, we have a new
package_lock.json
file and anode_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.
node_modules/
- 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 globallytsc --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:
{"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 compatibilitymodule
: Use CommonJS for compatibilityesModuleInterop
: Don't treat CommonJS and ES6 modules the sameforceConsistentCasingInFileNames
: To enforce consistent filename casingstrict
: Enable all strict mode family optionsskipLibCheck
: Skip type checking of declaration filesexclude
: Don't transpilenode_modules
, anything in thedist/
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.
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?
- Let's start with installing esbuild.
npm install esbuild --save-dev
- Next, let's create a
build.js
file to house our code that will actually run esbuild.
1const { build } = require("esbuild");2const { dependencies, peerDependencies } = require('./package.json')34const sharedConfig = {5 entryPoints: ["src/index.ts"],6 bundle: true,7 minify: true,8 external: Object.keys(dependencies).concat(Object.keys(peerDependencies)),9};1011build({12 ...sharedConfig,13 platform: 'node', // for CJS14 outfile: "dist/index.js",15});1617build({18 ...sharedConfig,19 outfile: "dist/index.esm.js",20 platform: 'neutral', // for ESM21 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 ourpackage.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 filesoutfile
: 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 ouresbuild
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:
1const { build } = require("esbuild");2const { dependencies, peerDependencies } = require('./package.json');3const { Generator } = require('npm-dts');45new Generator({6 entry: 'src/index.ts',7 output: 'dist/index.d.ts',8}).generate();910const sharedConfig = {11 entryPoints: ["src/index.ts"],12 bundle: true,13 minify: true,14 external: Object.keys(dependencies).concat(Object.keys(peerDependencies)),15};1617build({18 ...sharedConfig,19 platform: 'node', // for CJS20 outfile: "dist/index.js",21});2223build({24 ...sharedConfig,25 outfile: "dist/index.esm.js",26 platform: 'neutral', // for ESM27 format: "esm",28});
Compiling our code
- Now that our
build.js
file is all built out, let's add a script to ourpackage.json
.
{"scripts": {"build": "node build.js",}}
Now if we run
npm run build
in our terminal, we'll see adist
folder is created that contains anindex.js
,index.esm.js
, andindex.d.ts
file.Since our TypeScript files from
src
are being compiled, bundled, and minified, we don't want to include the contents of thissrc
folder in our NPM package. Just our newdist
folder. To do this, we can add afiles
field to ourpackage.json
to tell NPM which files we want to include when we publish our package (some files, likepackage.json
andREADME.md
are automatically included). This will also help reduce the file size of our package.
{"files": ["dist/*"]}
- To make sure NPM publishes our package correctly with the different compiled files that were generated, we need to add a few more fields:
{"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
- 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.
- 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
- 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 runnpm 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 publishnpm noticenpm notice 📦 my-package@0.1.0npm notice === Tarball Contents ===npm notice 357B README.mdnpm notice 231B dist/index.d.tsnpm notice 260B dist/index.esm.jsnpm notice 853B dist/index.jsnpm notice 1.0kB package.jsonnpm notice === Tarball Details ===npm notice name: my-packagenpm notice version: 0.1.0npm notice filename: my-package-0.1.0.tgznpm notice package size: 1.5 kBnpm notice unpacked size: 2.7 kBnpm notice shasum: 59ca63afc0fb706d534fda7b51b5fe5be60ae4d1npm notice integrity: sha512-88tnfkqVQVTeR[...]lqKOnxL5wsH1Q==npm notice total files: 5npm 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!
- 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
: formattingtslint
: linting for TypeScripttslint-config-prettier
: a preset to prevent conflicts betweentslint
andprettier
formatting rules
- Create a
tslint.json
file in the root of the project.
{"extends": ["tslint:recommended", "tslint-config-prettier"]}
- Create a
.prettierrc
file in the root of the project. Change these rules to fit your preferences.
{"printWidth": 120,"trailingComma": "all","singleQuote": true}
- Now that our linting and formatting rules are configured, let's add some scripts in our
package.json
.
{"scripts": {"format": "prettier --write \"src/**/*.ts\"","lint": "tslint -p tsconfig.json"}}
- Now if we run our new commands, our TypeScript
src
files will be formatted withprettier
and linted withtslint
.
npm run lintnpm 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!
- Install testing libraries as dev dependencies, since we only need to run tests during development.
npm install --save-dev jest ts-jest @types/jest
- 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.
/** @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.
- Update the
test
script in ourpackage.json
.
{"scripts": {"test": "jest --config jest.config.js"}}
- Now let's write a simple unit test! Let's create a file in
src
calledindex.test.ts
. Since this is a tiny package with just one file insrc
, we can create a single test file (rather than creating a__tests__
folder and putting our test file in there).
import { sum } from "./";describe("sum function", () => {it("adds 2 numbers", () => {expect(sum(1, 2)).toBe(3);});});
- 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.jsPASS src/index.test.tssum✓ adds 2 numbers (1 ms)Test Suites: 1 passed, 1 totalTests: 1 passed, 1 totalSnapshots: 0 totalTime: 1.627 sRan 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
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 ourpackage.json
.
{"scripts": {"prepare": "npm run build"}}
prepublishOnly
runs beforeprepare
, but only if we're runningnpm publish
. Here, we can run our linter and tests to avoid publishing any bad code.
{"scripts": {"prepublishOnly": "npm run lint && npm test"}}
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.
{"scripts": {"preversion": "npm run lint && npm test"}}
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 thisversion
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.
{"scripts": {"version": "npm run format && git add -A src"}}
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.
{"scripts": {"postversion": "git push && git push --tags"}}
- If we've done everything up until this point, our
scripts
in ourpackage.json
should look like this:
{"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:
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 😊