An ESBuild Setup for TypeScript
Historically, I have despised dealing with JavaScript build tools. For years it seemed to me a frustratingly fragile enterprise; so much so that I spent time building my own tools just to avoid the mess. I am happy to share that this area of the ecosystem seems to finally be maturing. I was pleasantly surprised by what I found when I took time to re-evaluate today’s options.
I spent the month of May spinning up a new Vanilla JS Web Components project using native modules and import maps with no transpiler or bundler. It was a tremendously productive way to get started, and I highly recommend starting new projects this way. Being free of the constraints of tools and even libraries/frameworks, with the raw web at your fingertips, can enable you up to better explore the optimal architecture for a project.
That said, a couple of weeks ago I reached a point where I began to feel the pain of maintaining a growing codebase without significant support for large-scale refactoring and re-design. I also knew that shipping unbundled JavaScript to production was a no go and would need to be addressed eventually. Finally, there were some new JS features, such as Decorators, that I really wanted to use in my codebase, but couldn’t without a transpiler. So, it came time to make a few decisions.
First, I spent some time converting my project to TypeScript. While there were a few mild frustrations in the process, it didn’t take long, and I was able to get things working pretty easily. Along the way, I found and fixed a few bugs in my code. More so, I was able to see a broader set of design problems and inconsistencies once I went through the motions of building out the previously implicit types.
For this, I was thankful.
ASIDE: TypeScript isn’t the only way to achieve this. A lot can be done with JSDoc comments, for example. That’s not the focus of this article though, so I won’t dig into my decision to use TypeScript here. Be sure that when you are making a decision for your own project, you evaluate the current options as they relate to the constraints of your project.
With the conversion to TypeScript complete, I next needed to deal with bundling. So, I hesitatingly reached for a standard Webpack TypeScript configuration that I had from a previous project. I lived with that decision for about a week before I decided it was just too much friction. The month working with pure modules and import maps made the contrast very clear.
In Search of a Build Tool
The degree of dissatisfaction I had with my Webpack setup was apparently greater than my loathing of build tools and configurations because I decided to re-evaluate the market. Before doing so, I needed to outline my requirements. I came up with what I felt was a relatively short and reasonable list tailored to my project:
- Support TypeScript, including transpilation of Stage 3 Decorators.
- Work both for production builds and developer inner loop flow, e.g. build/watch/serve/reload.
- Has a simpler configuration than Webpack for a feature-equivalent setup and doesn’t take me a lot of work to write, maintain, and update the configuration.
- Has improved build times over Webpack and eliminates other annoyances in the workflow I experienced on my project.
- Has as few dependencies as possible.
- Stable, healthy open-source project with active contributors and a permissive license.
I started by looking at Vite. While I was guarded due to the hype around this project, I had heard some good things from people I trust. I had my list of requirements before looking at technologies, which I felt would give me a solid initial basis for objective evaluation.
It did.
Right away I learned that Vite didn’t support my very first requirement:
- Supports TypeScript, including transpilation of Stage 3 Decorators.
While Vite supports TypeScript, it only does so by erasing the types and bundling plain JS. This still seemed fine as long as there was a simple plugin that would enable me to opt-in to the real TypeScript compiler for my need. Without much work I found a thread where the Vite lead had indicated rather emphatically that Vite doesn’t and explicitly planned not to support using the actual TypeScript compiler. Well, that made my decision pretty easy.
ASIDE: Vite is a highly opinionated tool. If your opinions and requirements match Vite’s then it’s likely very fast and easy to use. If they don’t, it’s probably not the tool for you. As I’ve stated here and elsewhere, start with your requirements first, before looking at tools, rather than let a tool dictate your architecture and requirements.
Next, I took a look at Parcel. Parcel looked promising. It seemed to meet all my requirements. So, I made a go at getting it set up. Without getting into all the specifics, my project needed to use a few dependencies via script tags with special handling. Parcel seemed to have problems with this. I struggled for a few hours looking for plugins and trying to customize my setup, hoping to get the right combination that would enable both the build to succeed and for things to work at runtime.
I gave up. I reached the decision that even if I could get it to work, Parcel didn’t meet the following requirement in the context of my project:
- Has a simpler configuration than Webpack for a feature-equivalent setup and doesn’t take me a lot of work to write, maintain, and update the configuration.
I have a low tolerance for these situations and also knew I couldn’t devote endless hours to the process. So, I decided to move on and explore other options.
There are a lot of build options out there today. I didn’t have time to try them all. However, there seemed to be some common trends. Many of the tools were based on or inspired by either Rollup or ESBuild. So, I decided to look into these two more low-level tools.
I had used Rollup in the past for library builds, but not for apps. So, I took a look again. Unfortunately, it doesn’t include a development server. Its site points to Vite for that, which I had already ruled out.
That left ESBuild.
I’m guessing you knew this was coming because you read the title of this post 😉 Suffice it to say, I found that ESBuild met all my requirements. I was able to get everything working, including all the idiosyncrasies of my project, in very little time and with very little code.
I was also happy to be using core tech that other bundlers were actually building on top of. I’ve always been a fan of going straight to the metal. I like the power and flexibility this provides.
Furthermore, I was pleased to see that my node_modules
folder and package.json
files were very small. Here’s the full list of packages in my node_modules
folder:
@esbuild
esbuild
esbuild-plugin-tsc
strip-comments
tslib
typescript
That’s it! And here’s what my full package.json
dependency list looks like:
"dependencies": {
"tslib": "^2.5.2"
},
"devDependencies": {
"esbuild": "0.17.19",
"esbuild-plugin-tsc": "^0.4.0",
"typescript": "^5.0.4"
}
This was even better than what I had hoped for. 🎊
Setting Up ESBuild for TypeScript
To set up a basic TypeScript project with ESBuild, you’ll need to install both esbuild
and esbuild-plugin-tsc
.
npm install --save-dev esbuild esbuild-plugin-tsc
This second dependency is only needed if you want to actually use the official TypeScript compiler for transpilation. Otherwise, you don’t need it.
NOTE: See my
package.json
listing above for the exact versions these steps pertain to.
With these dependencies installed, I wanted to create two tasks I could run:
npm start
— Build and watch my source code while serving up myindex.html
page, rebuilding and refreshing the page when my code changes. Unminified code and source maps should be generated, and the setup should work well with VS Code Debugging.npm run build
— Build a fully minified version of my source code ready for deploy.
Both of these commands needed the same core ESBuild configuration. So, I created a settings.js
module that exports a single function that can be used to create the shared settings.
settings.js
import esbuildPluginTsc from 'esbuild-plugin-tsc';
export function createBuildSettings(options) {
return {
entryPoints: ['src/main.ts'],
outfile: 'www/bundle.js',
bundle: true,
plugins: [
esbuildPluginTsc({
force: true
}),
],
...options
};
}
This function takes an options
object which it spreads into the core settings, allowing the caller to add to or override the base settings, as you’ll see shortly. The shared settings themselves are pretty straightforward:
entryPoints
— This is pointing to the main entry point of my application, which resides in themain.ts
file under mysrc
folder.outfile
— This is where I want my bundle to be written to. My project has awww
folder where theindex.html
, images,manifest.json
, and other static files live. I needed the bundle to be generated alongside those files.bundle
— Yes, please bundle my code.plugins
— There are various plugins available for ESBuild. I only need to ensure that the TypeScript compiler is used. For that, I pass theesbuild-plugin-tsc
plugin to ESBuild. There are various options for this plugin. I only had to configureforce: true
to ensure that TS was used for everything, which was needed to get Stage 3 Decorators working.
ASIDE: Folks may be concerned that using TypeScript directly would be slow. For my project, it was blazing fast. It would have to slow down significantly to even come close to what I was experiencing with Webpack. So, I think this is the right decision for this project at this point in time. I always revisit decisions like this periodically though. I recommend the same to you. As a project evolves, its needs evolve and may become misaligned with tools, architecture, etc. This happens to be a strong reason why you’ll want to start with simple, easily changeable decisions. You don’t know much at the beginning, particularly how things will need to change later on, and you don’t want to be locked into things to a degree that it harms your team, product, or business.
With the common settings helper in place, let’s see how to implement the build
command.
build.js
import * as esbuild from 'esbuild';
import { createBuildSettings } from './settings.js';
const settings = createBuildSettings({ minify: true });
await esbuild.build(settings);
That’s all there is to it. I import esbuild
and I import my settings
helper. I call my helper to create the settings object, augmenting it with the unique build settings that I need to tell ESBuild to minify my code. Then, I pass the settings object to the build
method and out pops my minified bundle.js
file in my www
folder.
Ok, so what about the developer inner loop? Here’s how I implemented my serve
task.
serve.js
import esbuild from 'esbuild';
import { createBuildSettings } from './settings.js';
const settings = createBuildSettings({
sourcemap: true,
banner: {
js: `new EventSource('/esbuild').addEventListener('change', () => location.reload());`,
}
});
const ctx = await esbuild.context(settings);
await ctx.watch();
const { host, port } = await ctx.serve({
port: 5500,
servedir: 'www',
fallback: "www/index.html"
});
console.log(`Serving app at ${host}:${port}.`);
There’s a little more to this, but not much, and it’s all well-documented and standard code.
First, I create my common settings, the same as I did in my build
task. Like before, I need to customize it a bit. The sourcemap
option simply enables source maps. The banner
option is more interesting. The js
specified in the banner
property will be verbatim injected into the beginning of the bundle. This particular bit of code connects to the ESBuild server through server sent events so it can be notified when a change occurs in the watched resources. When it receives the change
message, it reloads the browser.
Next, we use esbuild
to create a context
object from our settings, which will be shared by two integrated tasks:
- By calling
watch
we tell ESBuild to build our code and watch for changes. - Then by calling
serve
we tell ESBuild to serve ourwww
directory on localhost port5500
. We also provide a fallback page to use for 404s, which works great for SPA scenarios.
ASIDE: From an API design perspective, I like that ESBuild clearly separates the concern of watching from the concern of serving, while allowing them to be integrated together through a shared context.
That’s all I really needed at the time, but I learned it was trivial to also have ESBuild analyze bundles. So, I went ahead and wrote one additional script for that. Here’s what that looks like.
analyze.js
import * as esbuild from 'esbuild';
import fs from 'node:fs';
import { createBuildSettings } from './settings.js';
const settings = createBuildSettings({ minify: true, metafile: true });
const result = await esbuild.build(settings);
const mode = process.env.npm_config_mode;
if (mode === "write") {
fs.writeFileSync("build-meta.json", JSON.stringify(result.metafile))
} else {
console.log(await esbuild.analyzeMetafile(result.metafile, {
verbose: false,
}));
}
Similar to the previous scripts, we create our common settings. The important customization in this case is the metafile
property. This tells ESBuild to generate a file with all sorts of information about your build. When you invoke build
the result
will then contain the metafile
property, which you can use however you see fit. I wrote my script to take an argument of --mode=write
so that I could determine whether to write the file to disk or display a summary in the console. If you write the file out, you can upload it to the ESBuild Bundle Size Analyzer, which will give you a visual representation of your bundle, along with interactive drill-down.
Polish
There are a few additional details of my setup that relate to ease of use and everyday workflows.
First, I always want to keep my project organized. So, I put all my build scripts in a build
folder at the root of my repo. Then, I set up package scripts to execute them like so:
"scripts": {
"build": "node ./build/build.js",
"start": "node ./build/serve.js",
"analyze": "node ./build/analyze.js"
},
Next, since I’m using VS Code as my editor, I wanted to make sure I could use its debug tool integration. To do this, I created a .vscode
folder at the root of my repo, to which I added the following launch.json
file.
{
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "launch",
"name": "Launch Edge against localhost",
"url": "http://localhost:5500",
"webRoot": "${workspaceFolder}/www"
}
]
}
There are a couple of important details to note here:
- There are various options for
type
but I chosemsedge
because it enables me to use the DOM inspector directly from VS Code, in addition to the normal JS/TS debugging. - The
request
needs tolaunch
a browser in a special mode. You canattach
but I found it to be less reliable and more work. - The
url
includes the port5500
, which I previously configured as part of myserve
task. - The
webRoot
needed to point to mywww
folder, which is also the root that I’m serving from.
NOTE: You’ll also need to install Microsoft Edge Tools for VS Code, if you want to use the above configuration.
With that in place, my workflow involves these steps:
- Run
npm start
to build, watch, and serve my project. - Use VSCode to launch the browser.
- Place breakpoints, inspect TS, inspect DOM, etc. in VS Code as needed during the course of development.
If I want to create a production build, I run npm run build
and if I want to analyze my bundle, I run npm run analyze
for a quick analysis or npm run analyze --mode=write
to output the metafile that I can view on the ESBuild site.
Wrapping Up
Admittedly, my project is relatively small and simple. Each project is different and warrants an honest assessment of its unique requirements. So, when it came time to introduce a bundler, I took time to think about those needs. Then, I used my basic set of requirements to help me narrow down the options until I landed on ESBuild.
In the end, I find myself quite happy not only with the performance (one of my initial motivators) but with how simple the setup is and how few dependencies are involved. If the needs of my project change down the road, I have access to the low-level infrastructure that many other tools are built on, so there’s a good chance I can adjust settings and come up with what I need. If not, that’s ok too because the solution is low risk due to its simplicity, small investment, and decoupling from my application, to which I made not a single change while updating the build.
If you enjoyed this look into configuring ESBuild for TypeScript, you might want to check out my Web Component Engineering course. I’d also love it if you would subscribe to this blog, subscribe to my YouTube channel, or follow me on twitter. Your support greatly helps me continue writing and bringing this kind of content to the broader community. Thank you!