DEV Community

loading...
Cover image for Developer Dark Arts: Default Exports

Developer Dark Arts: Default Exports

Nate Clark
A JS Fullstackappotamus who strives to do more with less. Avid tinkerer in all things fullstack. Actively grazing on JavaScript, React, GraphQL, NodeJs, Docker, Serverless, IoT.
Originally published at codingzeal.com ・Updated on ・5 min read

So you are starting a ✨ shiny new greenfield 🌱 project. Congrats, not many of us get the opportunity to build something from the ground up. The architecture decisions you make today will impact everyone that comes after you. Hopefully, after a little convincing, you'll choose to avoid default exports.

With great power comes great responsibility

First some background...

The JavaScript Module

In modern JS, you have the ability to compartmentalize functionality into separate files commonly referred to as modules. Each module represents a single unit of work, an entity definition, or a combination of both. Each module has its own lexical scope which is academia's fancy term for variable scoping... which is my fancy term for the concept that things inside a module are not accessible outside of said module. Things being functions, variables, objects, etc. It also keeps your things from polluting the global namespace.


Exporting Things

Wait a second. If things are not accessible outside my module how is it that modules are useful?

This is where the export keyword comes into play. It defines a contract or sort of micro api to anyone who intends on using your module.

Let's say you have authored the greatest coin flip function ever created. Rather than copy/paste it everywhere you need to flip a coin you decide to extract it into a module appropriately called coinFlip. Ya know, to keep your code DRY.

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';
Enter fullscreen mode Exit fullscreen mode

In order to expose coinFlip to other modules you have an architectural decision to make.

Option 1: The default Export

Those of you coming from CommonJS modules might be familiar with the default export pattern. It defines what the default exported functionality is for the module.

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';

export default coinFlip; // <= default export
Enter fullscreen mode Exit fullscreen mode

This syntax exposes the coinFlip function in a way that allows consumers to import it via an unnamed alias.

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';

export default coinFlip;

// decisionMaker.js
import coinFlip from './coinFlip';
Enter fullscreen mode Exit fullscreen mode

I say "unnamed" because the name you give imported thing is arbitrary. I could have chosen to import it with any name really.

For example:

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';

export default coinFlip;

// decisionMaker.js
import aFunctionThatReturnsHeadsOrTails from './coinFlip'; // <= aliased import of a default export
Enter fullscreen mode Exit fullscreen mode

The local name of the imported thing is entirely up to the consumer. An important thing to note here is that there can only be one default export per module.

While not immediately apparent, default exports promote large object exports.

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';
const deprecatedFunction = () => 42;

const randomizer = {
  coinFlip,
  deprecatedFunction,
};

export default randomizer; // <= default exported object
Enter fullscreen mode Exit fullscreen mode

Seems legit right? I mean this would future proof your default export. Allowing you to add props to the object as you your module grows but it has one major downside. It isn't tree-shakeable. Tree shaking is the process in which consumers of your module transpile and minify their code. Its goal is to remove unused code branches.

Continuing with the above example, when exporting randomizer it cannot be split and dead branches cannot be groomed. Its export is atomic. Consumers get deprecatedFunction regardless if they are using it or not. Effectively bloating your code bundles with potentially dead code. This becomes increasingly important in the browser where file size has a major impact on load times and user experience.

NOTE: Large object exports are a problem regardless of default vs named exports. However default exports are more prone to this tree shaking pitfall because it's incredibly easy to add a prop to an existing exported object. Often times it's more appealing than refactoring to a named export.

Option 2: The Named Export

Ok, so large object exports are bad. What if I have multiple things I need to export from my module?

This is where named exports shine.

Named export syntax is different than default export in that it requires you explicitly name the things you're exporting.

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';

export { coinFlip }; // <= named export
Enter fullscreen mode Exit fullscreen mode

This syntax exposes the coinFlip function in a way that allows consumers import it via a well-defined name.

// coinFlip.js
const coinFlip = () => Math.random() < 0.5 ? 'heads' : 'tails';

export { coinFlip };

// decisionMaker.js
import { coinFlip } from './coinFlip';
Enter fullscreen mode Exit fullscreen mode

Unlike the default export syntax, you can export as many named exports as you need.

// coinFlip.js
const HEADS = 'heads';
const TAILS = 'tails';
const Result = { HEADS, TAILS };

const coinFlip = () => Math.random() < 0.5 ? Result.HEADS : Result.TAILS;

export { Result, coinFlip };

// decisionMaker.js
import { Result, coinFlip } from './coinFlip';

const result = coinFlip();

if (result === Result.HEADS) {
  console.log('It was heads');
} else {
  console.log('It was tails');
}
Enter fullscreen mode Exit fullscreen mode

What if you don't like the exported name or it is named the same as another local variable? Like the default export, when importing you can alias named exports however you want.

// decisionMaker.js
import { Result as DiceRollResult, diceRoll } from './diceRoll';
import { Result as CoinFlipResult, coinFlip } from './coinFlip';

// ...
Enter fullscreen mode Exit fullscreen mode

Option 3: Mixed Exports

Default and named exports are not mutually exclusive. You could have a module with a default export and named exports.

const HEADS = 'heads';
const TAILS = 'tails';
const Result = { HEADS, TAILS };

const coinFlip = () => Math.random() < 0.5 ? Result.HEADS : Result.TAILS;

export { Result };

export default coinFlip;
Enter fullscreen mode Exit fullscreen mode

You'll most often see these πŸ¦„ unicorns while working on brownfield projects that started with default exports and later added functionality to a module. Since default exports don't allow for multiple exports they added named exports to accommodate. That said, I doubt anyone has ever intended to start a project with this export pattern.


Which Option Should I Choose?

Here are a few of the pros & cons that I've seen

Default Exports

  • βœ… Familiar to devs migrating from legacy CommonJS modules
  • ❌ Leaves naming up to consumers which doesn't enforce any consistent naming conventions
  • ❌ Restricted to a single exported thing per module
  • ❌ Promotes large object export anti-pattern
  • ❌ Makes tree shaking difficult or impossible in some cases
  • ❌ No editor autocomplete/auto-import support

Named Exports

  • βœ… Allows for unlimited exports per module
  • βœ… Forces you to name things at time of writing, not consumption
  • βœ… Allows for easy tree shaking of unused code
  • βœ… Allows for editor autocomplete/auto-import
  • βœ… Safe find/replace refactoring
  • ❌ Forces consumers to use the exported module name (but allows aliasing)
  • ❌ If a named export is poorly named, you may run into a situation where you have to alias the import in every consumer

Mixed Exports

  • βœ… Familiar to devs migrating from legacy CommonJS modules
  • βœ… Allows for unlimited exports per module
  • ❌ Consumers of the module never know if they want the default export or a named export (adds guesswork)
  • ❌ Naming conventions are unclear
  • ❌ Safe find/replace refactoring is close to impossible
  • ❌ Encourages large object exports which lose the benefit of tree shaking

And the Winner is?

πŸ₯‡ Named Exports

Having navigated through and made changes to code bases from all three options, the code bases with named exports are by far the best option. There isn't any mind mapping required to import a given module or its things. There is nothing to decide when creating a new module. Simply export a named thing and go on with your day. Lastly and arguably the most important gain is readability.

What do you think? What am I missing? Where am I wrong? Let me know about your experience in the comments.


Today's post was brought to you by VSCode's "command palette" shortcut: Command+Shift+p

Discussion (0)