Upcoming and OnDemand Webinars View full list

Default exports or named exports: Why not both?

Jeremy W. Sherman

In NodeJS’s CommonJS module system, a module could only export one object: the one assigned to module.exports. The ES6 module system adds a new flavor of export on top of this, the default 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 that A 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 do import A from './a' and get the default export bound to A, or do import { A } from './a' and get the named export bound to A.

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 also export const B = 'B', the module consumer can write import { A, B} from './a' rather than needing to do import A, { B } from './a', because they can just grab the named A export directly alongside the named B export.

(It’s also a fun gotcha that you can’t use assignment-style destructuring syntax on the default export, so that export default { A, B, C } can only be destructured in a two-step of import Stuff from './module'; const { A, B } = Stuff. Exporting AB, and C directly as export { 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 the parseRequestJson and buildResponseJson 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.

Not Happy with Your Current App, or Digital Product?

Submit your event

Let's Discuss Your Project

Let's Discuss Your Project