Named ES6 imports aren't destructured objects

I finally figured out what it was that's been bugging me about ES6 modules.

It's like this. These two snippets of code do exactly the same thing:

Snippet P
const myModule = require('./my-module')
const { a, b } = myModule
Snippet Q
const { a, b } = require('./my-module')

However, these two snippets of code do not (necessarily) do the same thing:

Snippet X
import myModule from './my-module'
const { a, b } = myModule
Snippet Y
import { a, b } from './my-module'

Why do they not (necessarily) do the same thing?

In the first scenario (snippets P and Q), we're working with CommonJS modules. A CommonJS module always exports exactly one value. If my-module.js is a CommonJS module like:

module.exports = { a: 1, b: 2 }

then...

Snippet P
const myModule = require('./my-module')
// `myModule` is { a: 1, b: 2 }

const { a, b } = myModule // object destructuring
// `a` is 1
// `b` is 2
Snippet Q
const { a, b } = require('./my-module') // object destructuring
// `a` is 1
// `b` is 2

...the eventual values of a and b are the same.

Equally, if my-module.js says something like module.exports = null, snippets P and Q would fail on attempted destructuring. But they would fail in the same way, for the same reason.

But in the second scenario (snippets X and Y), we're working with ES6 modules. Unike a CommonJS module, an ES6 module exports multiple different values:

  1. exactly one default export, and
  2. zero or more named exports (the name is a JavaScript identifier and can't be default).

So, if my-module.js is an ES6 module like:

// default export
export default { a: 1, b: 2 }

// and some named exports
export const a = 3
export const b = 4

then...

Snippet X
import myModule from './my-module'
// `myModule` is { a: 1, b: 2 }

const { a, b } = myModule // object destructuring
// `a` is 1
// `b` is 2
Snippet Y
import { a, b } from './my-module'
// `a` is 3
// `b` is 4

...the eventual values of a and b are not the same.

The moral of this story?

This is not object destructuring:

import { a, b } from './my-module'

It looks like it is, but it isn't! It certainly is not destructuring of the default export of my-module.js. (That default export may not even exist. It could be undefined.) It is actually importing named exports from my-module.js.

If you actually want to get an object containing all the named exports, what we have to do instead is:

import * as myModule from './my-module'
// `myModule` is { default: { a: 1, b: 2 }, a: 3, b: 4 }

const { a, b } = myModule // object destructuring
// `a` is 3
// `b` is 4

The inevitable mild inconvenience of .default

Now, suppose we we want an automated process to turn an arbitrary ES6 module into a CommonJS module. Say, because we're writing a transpiler like Babel which we can use to make ES6 and CommonJS modules interoperable. Example input:

export default { a: 1, b: 2 }
export const a = 3
export const b = 4

There's a variety of "obvious" things we could do here which would be wrong. We can't just module.export the default export alone, because then the consumer can't access 3 or 4. We can't add the named exports as properties to the default export object, because the two as and bs would collide with one another.

What we find is that the most, arguably only, logical way to do this is to have the output CommonJS module export a single object containing all the original ES6 module's named exports along with its default export:

module.exports = { default: { a: 1, b: 2 }, a: 3, b: 4 }

This is, in fact, how most if not all ES6-to-CommonJS transpilers work.

And now we have an unfortunate scenario. Previously, with CommonJS modules, it was possible to set module.exports to be literally any JavaScript value we wished, such as a function:

module.exports = () => 5

and CommonJS consumers could do something cute like:

const getValue = require('./my-module')
// `getValue` is () => 5

const value = getValue()
// `value` is 5

But now that we have upgraded my-module.js from a CommonJS module to an ES6 module, it is impossible for us to structure our new ES6 module in such a way that when consumers transpile the ES6 back to CommonJS they still see the old behaviour and don't have to change anything. module.exports simply cannot be a function anymore. It has to be an object.

This is the best we can do:

export default () => 5

Compiled to CommonJS, that becomes:

module.exports = { default: () => 5 }

The old CommonJS consumer's code was:

const getValue = require('./my-module')
// `getValue` is { default: () => 5 }

const value = getValue()
// throws an exception, the object is not callable

and it has to be refactored to:

const getValue = require('./my-module').default
// `getValue` is () => 5

const value = getValue()
// `value` is 5

So, that's why.

Discussion (3)

2020-01-16 01:05:46 by qntm:

Spot the deliberate errors.

2020-07-17 11:50:17 by hoichi:

Well, technically, you (or a transpiler) could do something amounting to: ```js function f() {/*...*/} f.default = f; f.a = f.default.a = a; module.exports = f; ``` Not sure it won’t the mess worse though. Also, not sure how, say, TypeScript types will fit in.

2020-07-17 11:51:13 by hoichi:

Oh, now I see it’s plain text only. Sorry for that.

New comment by :

Plain text only. Line breaks become <br/>
The square root of minus one: