Mike Rockétt

To name, or not to name

In the world of JavaScript modules, default exports are really a compatibility layer for CommonJS-of-old. Named exports solve problems may developers experience – this post explores them.

#JavaScript — 20 December 2020

Back in 2015, we were graced with the wonderful world of modules, through the release of ECMAScript 2015, the 6th edition of the specification. This gave us a way to share code across several files, and keep our code pretty lean. Admittedly, I came into the picture a little late, in 2017, and learnt of their existence during my jQuery/​Vanilla -> Vue transition (and what a great transition that was).

When I began this journey, I did what everyone else was doing: export default.

Why? It did the job, that’s why.

But, I soon discovered a few problems with the approach (which came about thanks to CommonJS-of-old, where require() was a thing), and quickly learnt that named exports provide a few advantages over default exports.

Let’s run through these:

1. Tree-shaking

The concept of exporting a potentially humungous chunk of code only to find you’re going to use a tenth of it is a pretty common anti-pattern in use today.

Take this piece of hypothetical code I just made up:

export default {
 
setState: (key) => (state, payload) => {
StateManager.set(state, key, payload)
},
 
toggleBoolean: (key) => (state) => {
StateManager.toggle(state, key)
},
 
addState: (key) => (state, payload, prepend = false) => {
StateManager.add(state, key, payload, prepend)
},
 
// etc, etc.
}

This module exports an object containing helper functions for managing state. Depending on the context, you might not need to use it all. But, by using a default export, you’re including everything in your final bundle, even if it’s not needed. The usage of named exports solves this problem, and helps us eliminate dead, unused code:

export const setState = (key) => (state, payload) => {
StateManager.set(state, key, payload)
}
 
export const toggleBoolean = (key) => (state) => {
StateManager.toggle(state, key)
}
 
export const addState = (key) => (state, payload, prepend = false) => {
StateManager.add(state, key, payload, prepend)
}
 
// etc, etc.

Now, you can safely import { setState } from './state-helpers', knowing that toggleBoolean and addState, etc, will not be included in your final bundle, which reduces its size. Plus, linters do their jobs a little better, because they can actually verify the existence of a named export.

One might argue that the lack of a default export prevents the developer from importing everything in a file and using it as a ​’namespace’. For example, you might export default { a: ..., b: ... } to be used as utils.a and utils.b. However, using a default export for this exact purpose basically disallows tree-shaking. Instead, use named exports for both a and b, and then simply import * as utils from ... to achieve the same result whilst keeping the file shakeable in case you change your mind. Winner winner, chicken dinner.

2. Straight-forward Refactoring

This one is, well, pretty straight-forward. When you export default, your import can name the module whatever it wants. You could well do that with a named export, using the as keyword, but that’s an explicit* renaming* action (not a naming one), and likely taken for a very specific reason, such as a conflict.

Unsurprisingly, this practice isn’t all that uncommon — I’ve seen people abbreviate imports simply because they thought the names were too long or because it didn’t conform to a casing-standard they conform to. Looking at the bigger picture, this makes for a refactoring nightmare — if anything, refactoring just takes more time.

export default { /* ... */ }
 
// In the land of elsewhere, there are no limits ...
import something from './something'
import some_thing from './something'
import SomeThing from './something'

Using named exports forces developers to use things exactly as they’re named, and they can rename or alias things if they’d like to:

export const something = { /* ... */ }
// In the land of elsewhere, only the following is allowed ...
import { something } from './something'
import { something as theThing } from './something'

When searching an entire project for something you know the name of as part of a refactoring exercise, you have the guarantee that you wont miss anything, even if the import is aliased.

3. The All-Seeing IDE

Code editors and IDEs love helping you out. They have IntelliSense (or whatever they want to call it), which provides that super-handy autocompletion, along with auto-import. 

You’d be out of luck with a default export though — you can’t just start typing the name of something that it holds and expect the IDE to magically bring it in for you; it just doesn’t know what to call it. Perhaps ​“there’s a plugin for that“ that could help circumvent, but I haven’t seen one (nor have I searched).

By using named exports, you’re simply helping the IDE help you — it’s really that simple.

Start typing the name of the export, and you’ll be presented with a list of things it found. Hit enter and, boom, it’s imported for you, and you can carry on as you were. Want to refactor something globally? Chances are, it can do that for you too.

The Downside

Because of what the ecosystem contains to this very day, using third-party libraries will often leave you importing defaults in your project; left, right and centre. So, even if you decided to name your exports in your own project, it’s not all unicorns and rainbows. And that’s very unlikely to change, unless you’re in that sweet spot where you’ve written your own libraries, or just don’t need any at all. On the flip-side, though, if you’re naming your exports, it means you have the mindset to name your default imports properly — say, based on the name of a file in camelCase.

Conclusion

In closing: yes, I’m aware of the arguments that come up when discussing default vs named exports. Some of them stem from technicalities behind the scenes (you can technically import { default as something } from './something'), and some tend formulate semi-productive, never-ending discussions based on grey-area, personal-experience thinking.

That in mind, it’s time to be smart about how you make your code shareable. Enforcing something that can be enforced is not a terrible thing — if there’s a reason for it and it doesn’t sit in some sort of rule-book (but rather a guideline, for arguments’ sake), then there really is no harm in using named exports.

If your preferences don’t require you to lean on tooling and keep your code explicit, then perhaps it’s not for you. At the end of the day, it’s simply a choice.