Unambiguous Webpack config with Typescript

You can write your Webpack config in Typescript, and it’ll save you a huge amount of pain. Webpack’s docs would lead you to believe that using Typescript requires a hacky customized set up, but in fact it’s as simple as installing a single module and changing your extensions from .js to .ts!

If you’re familiar with the pains of Webpack and you just want to know how to write your config in Typescript, you can skip to the second section, Typescript to the Rescue.

Webpack is notoriously complex. Just ask the authors:

Problems to solve: Webpack is too low level; There are no reasonable set of defaults; Terminology doesn’t make sense; Too much boilerplate in handling environment logic; The CLI lacks scaffolding and initialization; Configuration is ‘convoluted’

And you don’t have to look far for Medium posts like this:

How much time have you spent messing around with your Webpack configuration to try make it performant, and work seamlessly with your HMR, your linter, TypeScript, Babel — so you could finally achieve developer Zen. How much time have you wasted finagling Webpack into doing what you want, instead of actually shipping product?

This ongoing thread on usability has some great ideas about how to improve the build tool. It makes me happy, because I for one have spent far too much of my energy messing around with my config instead of actually building cool stuff. To name a few issues that stand out:

  • Many ways to represent the same thing — To take just one example, Webpack’s loaders interface is incredibly permissive. A loader can be a string of just the name, an object containing a bunch of options, or a string with query params. You can have preLoaders and postLoaders, and you can have keys named loaders (plural) and/or loader (singular) or use (because why not?). You can pass options with options, or sometimes with query. I could go on. There are many ways to get the effect you want, though some ways are more correct than others. This is overwhelming for a newcomer, and it can lead to frustrating bugs for anyone. Here are just a few of the possibilities:
  • Opaque or non-existent error messages — It’s hard to track down bugs in your config when the only constraint enforced is that it exports a Javascript object. A shocking number of Stack Overflow answers about Webpack boil down to “you have a typo”, and checking for that is the first suggestion in this post entitled How to fix Webpack when it can’t find your modules. This is the tip of the iceberg when it comes to the ways Webpack silently allows you break things.
  • Easy to accidentally use old syntax — Webpack’s authors have taken pains to ensure backwards compatibility. This is nice of them, because it allows developers to be confident that Webpack won’t break underneath you (and this would be a disaster if it did, because it is the build tool of choice for an increasing portion of the internet). Plus, it doesn’t put any constraints on people’s designs or preferences. But this lack of constraints is also a nightmare, because it’s way too easy to use outdated APIs. New projects should use Webpack 2, but it’s nearly impossible to differentiate between them when viewing examples online. The two versions share a lot of naming conventions, and they don’t enforce usage consistency. As a result, most people are using a mix of Webpack 1 and 2 without knowing it.
  • Lack of sensible defaults — Webpack’s customizability is powerful, but it also leaves the newbie (or even someone who just wants to whip up something quickly) out in the cold. The interface does not guide you to reasonable defaults; it requires upfront knowledge of the underlying tool before you can even get started.

Each of these areas present serious challenges, but they are also emblematic of Webpack’s greatest strength: its flexibility. This is a classic instance of trade offs. Focusing on these shortcomings could sacrifice the other characteristics that make it such a powerful build tool. Webpack’s usability will continue to improve, but at the end of the day we have to accept the complexity that comes with anything this configurable.

Luckily, this is a problem we can solve with types, without sacrificing flexibility!

Typescript to the rescue

Around the time I started using Webpack, a friend introduced me to Typescript, and I’ve since used it in all of my side projects. I won’t gush about it here — I’ll let the Typescript core team do that — but I will say that it’s transformed the way I build software. Within weeks, I replaced every line of Javascript with strict Typescript. The only exception was my Webpack config files, because the tool did not support TS, as far as I could tell from documentation. I thought I’d have to manually compile the config into JS files before every compilation, which would be slow and a whole other source of frustration around Webpack.

So you can imagine my delight upon discovering that I was wrong! You can write your Webpack config in Typescript, and it’s easy. As far as I know, Webpack’s docs don’t mention this anywhere, but after some poking around I found this lone Stack Overflow post, which noted that there is a hint in the source code.

All you have to do is:

  1. Add ts-node as a devDependency.
  2. Replace your config’s extension from .js to .ts.
  3. Run webpack — config webpack.config.ts as usual.

It’s seriously that simple.

Discovery with types

You can do more, but even with these two tiny changes you’ll immediately see benefits. (I’ll get into extensions later.) In particular, the type definitions for Webpack are fantastic, and you get these for free. Typescript’s Autocomplete and type guards guide you to the right usage, without the friction of a Google search or guessing how what something is called. Rather than googling for overloaded terms like “resolve” or “module”, which will turn up many irrelevant or outdated results, you can see the options exposed by the interface in your text editor. And if you want visibility into what’s going on under the covers, Go To Definition jumps you right to the location in the .d.ts file.

The Typescript package for Sublime is great. You can add a shortcut to your keymap that makes code navigation effortless, even into /node_modules and other directories excluded by your sublime-project file:
{ "keys": ["ctrl+d"], "command": "typescript_go_to_definition" }

Let’s take the loader interface mentioned at the beginning of this post. Its flexibility can be mind-boggling. Everyone seems to make up their own thing, and it’s unclear why you’d use one approach over the other.

Digging into the types offers a lot of clarity. Types can make Webpack’s flexibility work with you, rather than against you. For illustration, let’s build out a simple pipeline to compile CSS files. We’ll start off this simple skeleton:

Note that in the following examples, I’ve pared down the comments and extra lines for the sake of focus. You can find the unabridged Webpack type definitions here.

By defining that config is of type webpack.Configuration, we can follow the types to fill in a huge amount of information.

1) Jump to definition for webpack.Configuration

Click into the type and Typescript’s Go To Definition (GTD) will jump us right to where the webpack.Configuration interface is defined. We can see that it takes an optional module of type Module, which we know is where we want to define our loader rules. Again, use GTD to jump to the Module interface definition.

2) Constrain module to the Webpack 2 interface

Here, we see that Module is a union type of OldModule or a NewModule. This is interesting. If we were new to Webpack, we might not have known that the webpack node module simultaneously supports both Webpack 1 and 2. We might not even know that there are two separate versions. This would give us a first hint.

Ah ha! Here’s our first peek at some of the complexity in the interface. We can see that OldModule and NewModule have different key names for the array of transformations applied to our files: loaders and rules. And both of these both take an array of Rules, implying they do mostly the same thing. Cool. Now when we see examples online with different key names, we can know a few things: (1) their interface is equivalent besides the different naming convention, and (2) if someone is using loaders, they’re using Webpack 1.

Let’s stick with the Webpack 2 NewModule way of doing things. If we GTD for Rule, we see:

Let’s start at the bottom. We see that Rule is a union of several specific types: LoaderRule, UseRule, RulesRule, and OneOfRule. Some of these are the union of Old* and New* rules. We only want to use Webpack 2 features, so let’s remove the Old* interfaces in order to focus on the options we care about.

The NewLoaderRule is just shorthand for the NewUseRule. The former applies a single loader, while the latter applies an array of loaders. I prefer to use use for all rules, including those with a single loader, because it makes usage more consistent throughout the config. It’s much more clear to a future developer that they doing the same thing, rather than having to dig through the types to understand that loader is just shorthand.

So let’s constrain our interface further by removing NewLoaderRule from our consolidated types. This leaves us with:

3) Constrain the rule interface to just the type we need

This simplifies our options a bit. Now, let’s dig into the question of what the “delegate rules” are doing. Until writing this post, I had never actually come across usage of RulesRule or OneOfRule, so this was new for me, too. They both take an array of Rules, so we could have nested Rules of arbitrary depth. Strange. The comments on the attributes of BaseRule explain:

  • rules is “an array of Rules that is also used when the [parent] Rule matches”, and
  • oneOf is “an array of Rules from which only the first matching Rule is used when the [parent] Rule matches”

In other words, the parent Rule acts as a filter, and any files that match that filter are then piped through the lists of Rules in rules and oneOf. For example, you might want to apply different loaders to the same file extension based on issuer (i.e. where a resource was imported). You could use rules to specify the use of style-loader only when the CSS file is imported in a Javascript file:

You could use oneOf to route to a specific loader based on a resource related match:

It’s good to know about rules and oneOf in case we stumble upon them in the future, but they’re overkill for what we’re trying to do: compile CSS files. So let’s remove RulesRule and OneOfRule from our consolidated types. This leaves us with:

Even if we were doing something more complex, we might still prefer to keep our rules defined at the top level. For instance, the oneOf example is equivalent to the following snippet, which is more explicit, more concise, and easier to understand:

4) Write your NewUseRule

Now, it’s clear what we need to do. We need to write a rule that defines a test and a use. We can get fancy and define other stuff, but those two keys are the bare minimum for satisfying the interface. Let’s go back to the skeleton we defined at the beginning and fill this in:

Awesome! Now we have a simple Webpack config that compiles CSS files. To summarize what we did here:

  1. Use GTD to view the definition for webpack.Configuration.
  2. Constrain the Module interface to just the Webpack 2 features.
  3. Constrain the rule interface to NewUseRule.
  4. Write the rule to pipe .css files through the style-loader.

This was a wall of text, but the process of paring down the interface to understand what you want to do is quick in practice. These steps can take seconds once you get the hang of diving into type definitions.

Documentation in your text editor

Not only are the Webpack type definitions thorough, but the maintainers have added extensive comments to everything. If you’ve ever wondered what the impact of changing your devtool option would be, you’ll see a nice comment like this above that line in the definition along with all of the possible values:

The type definitions often serve as better documentation than the project site. Examples and tutorials are invaluable, but the real challenge of Webpack comes whenever you’re doing something custom. The official docs can only enumerate so many of the permutations of config options. Even if they were to note each and every one, surfacing these examples would be a nightmare.

The type definition files have the added advantage of allowing you to stay within your text editor. I can often solve my problem without leaving Sublime, even when I’m learning about a brand new tool in Webpack’s extensive feature set. These tools allow me to discover the answers latent in the type definition when before I had to hunt them down.

On top of all this, the “documentation” provided by the types don’t go stale. Blog posts and examples in documentation can get out of sync with the tool as its version gets bumped. Webpack has improved rapidly in the past few years, so when working off of an example I’ve found online, I’m always concerned that it no longer applies. I never have this concern when working off the types. Their definitions are mapped directly to the interface you depend on, and updating them is part of changing the version. You never have to worry if your types are wrong.

Constraining the interface to what you need

To get the full benefits of writing your config in Webpack, you’ll want to add a few more things. One addition is you can add return types to various components of the config. For instance, I like adding the webpack.Configuration type to partials, because it ensures that they can the be webpackMerged in the top-level config file.

I also like to create a custom Options type, which can be passed into partial functions to build up the greater config. This is nice, because usually you only care about tweaking a few of the Webpack options between development, testing, and live environments. This allows you to constrain the massive number of possibilities to a much smaller interface. Of course you can already do this with typical Javascript by simply creating a wrapper function that takes a subset of the options as arguments, but with Typescript you have the added ability to follow which options are explicitly defined by the top-level config and which are downstream effects of those options. In short, Typescript allows you to constrain the interfaces you’re working with to just the parts that you care about.

In the earlier loader example, we went through the process of mentally consolidating the Loader interface. This was useful for minimizing the surface area of the interface to just what we needed. We can go one step further and enforce these types.

For instance, we can constrain the interface to use only Webpack 2 features. A common complaint is that it’s hard to distinguish documentation for Webpack 1 and 2, and it’s easy to inadvertently use an outdated approach. To avoid this, you can extend the webpack.Configuration type to limit the interface to just the style you want to use. Typescript lets you do:

So you can do something like this, if you wanted to constrain your resolve config to only the NewResolve type rather than the default type which also allows OldResolve:

You could enforce usage of only the NewUseRule just like in the loaders example at the beginning:

Now, your Config type makes sure that no one uses a different interface by accident. You won’t be able to breach the codebase’s convention without explicitly specifying the change in the interface. Webpack configuration is complex enough to hold in your own head, and disseminating the knowledge for how each piece should work is even harder in a large organization. Types can do a lot of this work for you. They take on much of the mental overhead associated with following conventions or disentangling all of the possibilities latent in Webpack.

Type annotations remove a lot of the pain created by Webpack’s complex and overly permissive interface. There are other ways the project could improve its usability, but Typescript goes a long way in solving these problems.

This post is just an initial set of ideas about how I’ve used Typescript’s tooling to improve my workflow — I’m excited to explore it further. I also want hear how others have used types (and other tools!) to improve Webpack’s developer experience, so don’t hesitate to reach out.

Thanks to John Backus for his help brainstorming and revising this post with me.

No time to help contribute? Want to give back in other ways? Become a Backer or Sponsor to webpack by donating to our open collective. Open Collective not only helps support the Core Team, but also supports contributors who have spent significant time improving our organization on their free time! ❤