How to replace Webpack with (almost) only TypeScript
The overhead required to ship modern web applications these days can be overwhelming and has spawned a whole ecosystem of tools, plug-ins and methods. I’ve used both Webpack and Rollup professionally and spent countless hours configuring, debugging, upgrading, and resolving incompatibilities… as I’m sure many others have as well.
I’ve made it a regular practice to reduce tooling and dependencies in my projects as an investment in my future sanity. One of these days I noticed TypeScript has a singe-file output mode — and wondered if that could be used to eliminate traditional bundling. Here’s what I found.
This is the TypeScript part of the example application we’re going to build (live demo):
It’s a simple React/TypeScript app with some NPM dependencies which definitely needs a build step to ship. It could be easily (👹) built with Webpack or Rollup as is. Now, let’s try that without a dedicated bundler.
The result can be inspected here but is not required to follow through.
For this project (or any other standard ES6 project) TypeScript is able to produce a one-file AMD build with wide browser compatibility out-of-the-box by using the outFile, target and module config parameters. This is the TypeScript config we’ll be using:
Running the TypeScript compiler with this config generates an ES3 AMD bundle of the project, not including NPM modules but explicitly referencing external imports as AMD dependencies. TypeScript is able to handle TSX and even JS/JSX projects through configuration, no plug-ins required.
Naturally, this application bundle does not work in the browser directly. It requires an AMD implementation and a way to load any referenced dependencies.
My first thought was using Almond but it didn’t solve the problem. It’s designed for existing bundling scenarios, so you’d need additional tooling (e.g. a bundler and/or the RequireJS optimizer). Dynamic loading with RequireJS is also not an option. What I really wanted was a lightweight solution that worked with script tags.
I couldn’t find an AMD implementation that did what I was looking for, so I had to write my own: SAMD is a static AMD implementation that allows including AMD builds and dependencies in standard script tags, in only 250 lines of code.
Using SAMD, the application bundle can be used directly in a website by including UMD builds of its dependencies from e.g. unpkg. This HTML page will just work:
SAMD infers module IDs from script src attributes, allowing the original NPM imports to be resolved from the application bundle. Note that the only build step needed here is TypeScript compilation.
This setup is already good enough for development:
- It works in modern browsers and only needs polyfills for older browsers.
- The application code is based on modern JS/TypeScript with ES6 imports.
- TypeScript’s watch mode may be used to continuously build the application bundle.
- Source maps are supported.
Because of unpkg’s CDN, this setup is fairly performant when using popular libraries. It is also widely compatible regarding browser support (the demo app even works in IE).
So far, so good. Going to production, we’d want to switch to the respective production builds of dependencies (e.g. react.production.min.js) and minify the application bundle through terser. We can easily use server-side templating or similar techniques to serve different HTML for development and production.
At this point, it makes sense to write a small shell script for the build step which could be as simple as this:
Self-hosting the dependencies is also trivial. Just copy the respective builds from node_modules to e.g. scripts/vendor and replace the unpkg URLs with local ones. When served with HTTP/2, this setup is sufficiently performant for a wide range of websites, definitely enough for small projects.
So far, we have shown how to transpile and bundle the application’s own code into one file with only TypeScript. The final step is bundling application and dependencies into a single bundle.
It seems almost possible to just concatenate all the scripts into one file — however, because UMD modules usually define anonymous AMD modules, SAMD is not able to identify individual module IDs in a bundle without user input, so bundling cannot work by simple concatenation.
For this use case, SAMD ships with a CLI that infers module IDs and injects them into given UMD module files. This CLI can also be used for concatenation. Here’s the final build script:
Take a moment to compare this shell script with a Webpack config (and all of the required dependencies). Admittedly, the dependency copying is boilerplate, but most of that is optional production optimization and shell scripts are just very versatile in general. Finally, there’s nothing to learn or configure besides TypeScript.
Success! A standard TypeScript/ES6 project bundled with (almost) only TypeScript. We’ve swapped out Webpack/Rollup/etc. + plugins + config (hundreds of megabytes, lots of headaches) for SAMD (250 LOC) and shell scripting. Here’s the package.json for reference:
The presented workflow has useful properties and is fully compatible with the web platform:
- Works with modern ES6/TypeScript projects, with little implications.
- Wide browser compatibility (even Internet Explorer).
- Loading of scripts is handled and optimized by the browser (async and defer are supported).
- Production optimizations are progressive and don’t impact development.
- Bundling is (almost) reduced to simple concatenation.
- Bundling does not require configuration; simple shell scripts are sufficient and provide additional flexibility.
Naturally, there are some limitations:
- Dependencies must provide AMD/UMD builds. Most popular libraries do, though.
- Multiple versions of dependencies are not supported.
- CommonJS is not supported; you must use ES6 imports/exports. This is the way forward, anyway.
- Non-JS imports (e.g. styles) are not supported.
- Polyfills have to be managed manually.
- No tree-shaking.
- I’ve ignored bundling/minifying styles or images. I believe relying on bundlers to do everything is part of the problem. Instead you can use the SCSS CLI and image processing tools in your build script and speed it up with concurrently if necessary.
It’s not a perfect solution, but I believe it works for many, many projects and is much simpler than most established web app bundling setups.
Thanks for reading — feedback is very welcome!