Webpack Configuration Done Right™

Special thanks to Eric Baer, Neal Granger & all the other smart people at MetaLab for their pervasive helpfulness, knowledge and feedback; special thanks to Tobias Koppers for his most excellent webpack framework.

Using some functional programming techniques and a little bit of lodash, we’ll look at turning a giant, monolithic webpack configuration that looks something like this:

export default {
target: 'web',
entry: 'main.js',
module: {
...
},
plugins: [
...
],
...
};
// For an unabridged example 😱 see: here.

Into a modular, reusable one that looks something like this:

import compose from 'lodash/fp/compose';
import stats from './stats';
import css from './css';
import babel from './babel';
const base = compose(
stats('stats.json'),
css({minify: true}),
babel()
);
export default base({target: 'web', entry: 'main.js'});
🚧 Important: These webpack configurations are written in ES6 and make use of functional composition from lodash. To read more about using ES6 in webpack configurations see this StackOverflow answer; to read more about functional composition with lodash see this Medium article.

Hello World

webpack is a lovely modern web platform build tool. webpack and its variety of plugins and loaders handle everything from JavaScript minification and tree shaking, to CSS modularization and asset inlining. For small projects that don’t use a lot of these features or many of the available loaders, having a single webpack configuration is often enough; but when a project grows, having to manage, share and modularize these configurations can become complicated without good tooling — avoiding death by a thousand cuts will make your platform team happy.

Configuration Partial Basics

The solution to this mess starts with having a collection of functions that take a webpack configuration as input and return a new webpack configuration as output; each one performs a small individual task.

One such example is making a function that adds a stats plugin to your configuration. The original configuration that you started with might have looked something like this:

import StatsPlugin from 'stats-webpack-plugin';
export default {
// ... webpack config
plugins: [new StatsPlugin('stats.json')],
};

But you could have any number of very disparate webpack configurations (one for testing, one for client builds, one for server builds, etc.) and still want to accomplish the same thing: inject the stats plugin into the list of plugins. One such function that does this might be as follows:

const stats = (config) => ({
...config,
plugins: [
...config.plugins || [],
new StatsPlugin("stats.json"),
]
});
export default stats;

It accounts for webpack configurations that are missing the plugins entry and appends a new stats plugin. You could do other things like check to see if a stats plugin already exists in the plugins, or make the stats plugin come first instead of last if it made sense for your use case.

With our new stats function we can now strip all the stats related functionality out of our main webpack configuration and call it.

import stats from './stats';
export default stats({
// ... ANY webpack config
})

This is all there is to the basic principle of it. There are more things we can do to make it better including: allowing configuration of the stats plugin, making the “add a plugin” pattern reusable, currying for composition, and environment detection.

Partial Configuration

Our previous partial was not “parameterized”; that is to say there was no way to configure its behaviour. Simply prepending an additional argument with whatever options you’d like is enough to solve the problem. Note that for reasons we’ll get into later, having your function with a fixed arity of two and the webpack configuration as the last argument will help make the function more composable.

const stats = (filename, config) => ({
...config,
plugins: [
...config.plugins || [],
new StatsPlugin(filename),
]
});
export default stats;

This new parameterized partial can be used just as easily as the last one except you now control the name of the output file:

import stats from './stats';
export default stats('stats.json', {
// ... webpack config
})

Partial Utilities

As is common in programming, instead of doing the same transformation over and over we can have a tool that does it for us, and then re-use that tool whenever we come across the same kind of pattern. Injecting plugins is one such pattern and a function for doing the injection might look like:

const plugin = (plugin, config) => ({
...config,
plugins: [
...config.plugins || [],
plugin,
]
});

Now we can make our previous stats function use this new plugin helper, reducing code and complexity:

import StatsPlugin from 'stats-webpack-plugin';
const stats = (filename, config) => plugin(
new StatsPlugin(filename),
config
);
export default stats;

This plugin function, and a host of other such utility functions compatible with both webpack@1 and webpack@2 are available as part of the library webpack-partial (disclaimer: I wrote it). The examples in following sections will make use of this library where applicable.

Composing Partials

Now that we have one partial for adding stats, we’d like to be able to add a bunch more and combine them together intelligently. Because the type signature for all partials is the same, options, config -> config (take as input options and webpack configuration, return webpack configuration), using functional composition makes composing configurations really easy. We simply need a bunch of functions of the form config -> config in order to be able to chain them together. By factoring out options with currying we can achieve that. Please go here for more information on this method.

Since JavaScript does not curry functions by default we need to wrap our partial in a curry call which we can get from lodash:

import curry from 'lodash/fp/curry';
import StatsPlugin from 'stats-webpack-plugin';
import {plugin} from 'webpack-partial';
const stats = curry((filename, config) => plugin(
new StatsPlugin(filename),
config
));
export default stats;

And now we can create pre-configured functions that operation only on webpack configurations:

import stats from './stats.webpack.config';
// The generation function signature of `base` is now exactly
// what we want: `config -> config`. This example only shows a
// single partial: `stats` being used to make `base`.
const base = stats('stats.json');
export default base({/* webpack config here */});

And combine them using the compose function:

// Also known as `flowRight` in the lodash docs.
import compose from 'lodash/fp/compose';
import stats from './stats.webpack.config';
import css from './css.webpack.config';
import babel from './babel.webpack.config';
// Same as before, except now a combination of fragments.
const base = compose(
// Result of each of these is `config -> config`.
stats('stats.json'),
css(),
babel(),
// Any other "fragments" go here.
);
export default base({/* webpack config here */});

This makes it really easy to mix and match webpack configurations. You can have a “common” configuration for your app that incorporates a bunch of smaller configuration fragments from prebuilt modules, and then you can have client and server configs that are composed from that common base.

/node_modules/some-module/webpack/css.webpack.config.js
/node_modules/some-module/webpack/babel.webpack.config.js
/config/webpack/partial/stats.webpack.config.js
/config/webpack/client.webpack.config.babel.js
/config/webpack/server.webpack.config.babel.js
package.json

In practice this pattern lets you store fragments of your webpack configuration in whatever way you like; you can have modules that store your configuration for quick and easy re-use or you can have fragments locally to share between multiple kinds of webpack builds. There are some examples of prebuilt configuration partials available here (disclaimer: I wrote some of these).

Environment Detection

This pattern is also great for universal webpack apps, because you have access to properties of the webpack configuration that you can use to guide your build. For example, if you use CSS modules the configuration is different for server and client — the server only needs the class name references and not the generated styles.

import {loader} from 'webpack-partial';
const css = (config) => {
if (config.target === 'web') {
return loader('css-loader?modules', config);
}
return loader('css-loader/locals?modules', config);
};
export default css;

Since we have access to the previous webpack configuration here we can detect what the target is going to be (for example web or node) and alter what we do accordingly.

One Last Trick

The variety of available functions in lodash/fp and this technique makes doing many common tasks in updating webpack configurations easy. For example, to set a specific entry value in your configuration:

import set from 'lodash/fp/set';
export default set(['entry', 'client'], './foo/index.js');

Other Solutions

Others have tried their hand at solving this problem, coming up with a handful of mechanisms like “archetypes” and configuration merging. While these techniques solve certain aspects of this problem, they tend to fall short somewhere.

“Archetypes” are predefined packages and scripts for building out an application; although the terminology comes from Formidable Labsbuilder, the concept is applicable in the general case (see, for example, with Walmart Labselectrode which uses gulp). You can use this pattern to have a single giant webpack configuration and build script bundled up inside the archetype module, and then you can share it with all your projects. Unfortunately this suffers very heavily from lack of control inversion; it couples your application very tightly to that one specific configuration. Although the problem of sharing configurations is solved, it’s impossible to modify or re-use bits and pieces if the needs of your application change. If you want try out a custom CSS loader, for example, there is now no way to do this without mucking about in the archetype internals and polluting other projects that have different requirements.

Alternatively, splitting a single webpack configuration into several partial chunks is another option. You pick the chunks you want to use and then attempt to intelligently merge configuration fragments together. There are many libraries available for doing this like webpack-merge. Unfortunately there are all kinds of cases that don’t work with this approach because a generic merge is just not intelligent enough. Remapping the entry key, for example, is particularly troublesome because it can be an array, an object or just a single string. When you merge two entry values what should the behaviour be? Merging simply loses all manner of specificity and makes it hard to reason about how your fragments are actually being combined.

Final Thoughts

Although the examples here show how to make webpack configuration management a little more sane, the same principles can be easily applied to anything else that uses some kind of predictable data structure for configuration. Data structures are often big and messy, and can describe complicated systems; having simple functions that operate on these structures free developers from having to manage them themselves.

Making good applications always starts with building a great developer experience, and having pre-made, battle-tested bits of composable configuration lets you get those apps started quickly and reliably without sacrificing the ability to make modifications easily later on.

Give composable webpack configurations a try on your next project. 🙏 🎉