In NodeJS’s CommonJS module system, a module could only export one object: the one assigned tomodule.exports
. The ES6 module system adds a new flavor of export on top of this, thedefault export.
A minimal ES6 module
A great example to illustrate this is this minimal module:
export const A = 'A'export default A
At first glance, you might think thatA
has been exported twice, so you might want to remove one of these exports.
But it wasn’t exported twice. In the ES6 module world, this rigs it up so you can both doimport A from './a'
and get the default export bound toA
, or doimport { A } from './a'
and get the named export bound toA
.
Its CommonJS equivalent
This is equivalent to the CommonJS:
const A = 'A'module.exports = { A, default: A,}
Why expose a symbol as both default and named exports?
Exposing it both ways means that if there is alsoexport const B = 'B'
, the module consumer can writeimport { A, B} from './a'
rather than needing to doimport A, { B } from './a'
, because they can just grab the namedA
export directly alongside the namedB
export.
(It’s also a fun gotcha that you can’t use assignment-style destructuring syntax on the default export, so thatexport default { A, B, C }
can only be destructured in a two-step ofimport Stuff from './module'; const { A, B } = Stuff
. ExportingA
,B
, andC
directly asexport { A, B, C }
in addition to as part of the default export erases this mismatch between assignment destructuring and import syntax.)
Why use default exports at all?
- Simplify usage:Having a default export simplifies import when the person importing the module just wants the obvious thing from there. There’s simply less syntax and typing to do.
- Signal intent:A default export communicates the module author’s understanding about what the primary export is from their module.
Intent examples
Example: Express handler: Main and helpers
If there’s a main function and some helpers, you might export the main function as the default export, but also export all the functions so you can reuse them or test them in isolation.
For example, a module exporting an Express handler as its default might also export theparseRequestJson
andbuildResponseJson
de/serializer functions that translate from the JSON data transport format into model objects and back. This would allow directly testing these transformations, without having to work at a remove through only the Express handler.
Example: API binding: Related functions with no primary
In the case where the module groups related functions with no clear primary one, like an API module for working with a customer resource./customer
, you might either omit a default export, or basically say “it’s indeed a grab bag” and export it both ways:
export const find = async (options) => { /* … */ }export const delete = async (id) => { /* … */ }export default { find, delete,
Anchored API increases context
If you similarly had APIs for working with./product
, this default export approach would simplify writing code like:
import customer from './resources/customer'import product from './resources/product'export const productsForCustomer = async (customerId) => { const buyer = await customer.find(customerId) const products = await Promise.all( buyer.orders .map { order => order.productIds } .map { productId => product.find(productId) } ) return products}
Effectively, all the functions are named with the expectation that they’ll be used through that default export – they expect to be “anchored” to an identifier that provides context (“this function is finding a customer”) for their name. (This sort of design is very common in Elm, as captured in the package design guideline that“Module names should not reappear in function names”. Their reasoning behind this applies equally in JavaScript, so it’s worth reading the two paragraphs.)
Unanchored API requires aliasing and repetition
If you hadn’t provided a default export with all the functions from both resources, you’d instead have had to alias the imports:
import { find as findCustomer } from './resources/customer'import { find as findProduct } from './resources/product'export const productsForCustomer = async (customerId) => { const buyer = await findCustomer(customerId) const products = await Promise.all( buyer.orders .map { order => order.productIds } .map { productId => findProduct(productId) } ) return products}
The downsides of this are:
- The API consumer’s aliasing workload scales linearly with the number of identifiers they want to use.
- Different consumers may alias them to different names, which makes code written against the API less uniform (and harder to rename through search-and-replace).
The upside is:
- It’s clear from the import list precisely which identifiers you’re importing.
This could be fixed by the module author embedding the module name in each exported identifier, at the cost of the author having to repeat the module name in every blessed export.
Summary
- Default exports, from a CommonJS module point of view, amount to sugar for exporting and importing an identifier named
default
. - There are good reasons to use both default and named exports.
- You can make your codebase more uniform and readable by taking advantage of default exports in consuming and designing APIs.
During his tenure at BNR, Juan Pablo has taught bootcamps on macOS development, iOS development, Python, and Django. He has also participated in consulting projects in those areas. Juan Pablo is currently a Director of Technology focusing mainly on managing engineers and his interests include Machine Learning and Data Science.