tl;dr - This is seriosly the shortest explanation, but if I must… imports may mess with exported getters.

Part 1 - Getters

What is a getter?

A getter is an object property that secretly invokes a function every time it’s looked up.

const foo = {
  get bar() {
    return 'bar'
  },
}

This means when your code references

foo.bar

it is as if it’s actually doing

foo.bar()

Getters are a bit dirty.

That function is called every time you reference the property.

const foo = {
  get bar() {
    console.log('Hello.')
  },
}

foo.bar
foo.bar
foo.bar

Output

Hello.
Hello.
Hello.

Getters break references.

Because of the hidden get function, getters can leave you scratching your head.

const foo = {
  get bar() {
    return []
  },
}

console.log(foo.bar === foo.bar)

Output

false

This is false because every invocation of the getter returns a new [].

🤔

foo.bar === foo.bar
false

🤪

const x = foo.bar // executes getter, new []
const y = x // assigns [] from x

console.log(x === y) // true
console.log(y === foo.bar) // false, executes getter, new []

How can you tell if something is a getter?

You can find out if a property is a getter with getOwnPropertyDescriptor.

const foo = {
  get bar() {
    return 'bar'
  },
  baz: 'baz',
}

console.log(Object.getOwnPropertyDescriptor(foo, 'bar'))
console.log(Object.getOwnPropertyDescriptor(foo, 'baz'))

Output

// This is a getter.
{ get: [Function: get bar],
  set: undefined,
  enumerable: true,
  configurable: true }

// This is not.
{ value: 'baz',
  writable: true,
  enumerable: true,
  configurable: true }

Getter Recap

  • Getters use an invisible get function, implicitly called when accessing an object’s property.
  • Every time the property is access, the function is called.
  • You can expose getters using getOwnPropertyDescriptor.

Part 2 - Module Getters

Can you export a getter from a module?

module.exports

You can export them as a top-level module properties with module.exports.

foo.js

let count = 0

module.exports = {
  get count() {
    return count++
  },
}

saladfingers.js

import { count } from './foo'

console.log(count)
console.log(count)
console.log(count)

Output

0
1
2

As we can see, this provides access to this state within the foo module.

export default

export default works in a similar fashion.

foo.js

-module.exports = {
+export default {

Except there’s no named export for the getter, as it needs to be wrapped within an object.

saladfingers.js

import { count } from './foo'
import foo from './foo'

console.log(count)
console.log(count)
console.log(count)
console.log(foo.count)
console.log(foo.count)
console.log(foo.count)

Output

undefined
undefined
undefined
0
1
2

The only way to export a top-level getter is within the default module export.

What do getters look like when they’re imported?

Get ready for fun.

Combining getters exported with export default and different import styles has many results.

exports left: foo.js; middle: saladfingers.js; right: output

import { count } from './foo'

This is undefined. As we found above, there are no named getter exports.

import foo from './foo'

This creates a local foo object assigned from the default export of our foo module. foo.count is our getter, as expected. Note that when we first reference foo.count, it is 0. This tells us it has not been referenced yet.

import * as wholeFoo from './foo'

The import * as style creates a local wholeFoo with all the module’s exports copied onto it. Since our module only exports a default, wholeFoo has only that.

Output

wholeFoo.count undefined
wholeFoo.default.count 1

Happily, the default contains our count getter, still incrementing as expected.

Aside: Either you skipped down to this point or you’ve read the whole thing. If the latter is true, I hope it makes sense. If the former is true, I don’t blame you. I scan constantly. It’s part of the info deluge; it’s impossible to read everything. Anyhow, I hope this is helpful and makes sense. If not, try and read the whole thing. If it _still_ doesn’t make sense, track me down in real life, beat me up, and take my wallet. Now, the final segment. This is what we’ve been working up to.

Madness

Let’s change our exported getter to use the module.exports form and combine all this to demystify a bit of insanity.

-export default {
+module.exports = {
   get count() {
+    console.log('calling getter')
     return count++
   },
 }

We’ve also added a console.log to see when the getter is accessed.

Basic Import (ImportDefaultSpecifier)

import foo from './foo'

console.log('foo.count', foo.count)

Output

calling getter
foo.count 0

There’s nothing surprising here. A local foo object contains the exports from out module. When we access foo.count the getter is called.

Named Import (ImportSpecifier)

import { count } from './foo'

console.log('count', count)

So now we can do a named import, though we’re still not doing a named export. That’s OK. This is really just sugar for “import foo into this empty object, then pluck out and declare the count value.”

When we access it, it’s naked - there’s no containing object. It only existed for a moment while we imported. So now, every time we reference this apparent global value, the hidden getter is executed.

Output

calling getter
count 0

This still makes sense, we’ve just hidden the object the getter is working in. Now let’s depart from the realm non-exploded brains.

Aliased Import (ImportNamespaceSpecifier)

We’ve changed the import to map all the exports to a local object called wholeFoo. That means we end up with wholeFoo.count and wholeFoo.default.count. Cool! Now we’ve got two to pick from.

exports-two

When we run it, everything looks good. We reference them each and we can see the getter called twice. Feel free to turn off your computer and go play.

If you haven’t followed along, this may upset you…

import * as wholeFoo from './foo'

console.log('wholeFoo.count', wholeFoo.count)
console.log('wholeFoo.count', wholeFoo.count)
console.log('wholeFoo.count', wholeFoo.count)
console.log('wholeFoo.default.count', wholeFoo.default.count)
console.log('wholeFoo.default.count', wholeFoo.default.count)
console.log('wholeFoo.default.count', wholeFoo.default.count)

Output

calling getter
wholeFoo.count 0
wholeFoo.count 0
wholeFoo.count 0
calling getter
wholeFoo.default.count 1
calling getter
wholeFoo.default.count 2
calling getter
wholeFoo.default.count 3

nope

Now, dear reader, this is why I’ve taken the time to write all this down. I stumbled across this problem from this side. There was almost too much WTF to bear. I thought I was pretty good with JS. Maybe Babel is wrong. Maybe Node. Maybe (insert other periperhal systems I was actually working on) are jacked up.

Nah, it’s none of that. It only looks like wicked dark magic when we’re ignorant. Powered with the info above, we can pick this apart nicely and it will make sense. It’s just surprising.

Baby Steps

Let’s just do this.

import * as wholeFoo from './foo'

Output

calling getter

WTF you say? Hold that thought. Just note that we haven’t referenced count at all, but something has.

Let’s use our getOwnPropertyDescriptor trick to look at what we’ve imported.

import * as wholeFoo from './foo'

console.log(Object.getOwnPropertyDescriptor(wholeFoo, 'count'))
console.log(Object.getOwnPropertyDescriptor(wholeFoo.default, 'count'))

Output

calling getter
{ value: 0, writable: true, enumerable: true, configurable: true }
{ get: [Function: get count],
  set: undefined,
  enumerable: true,
  configurable: true }

wholeFoo.count is just a simple property with the value 0.

wholeFoo.default.count is our getter.

Now it’s clear what called our getter. When we imported the foo module as an alias, the JavaScript monsters created an object wholeFoo for us then copied all the exports from the module onto it. This includes default, which behaves as expected, but it also expanded the contents of default. In doing so, it accessed count, which is why it’s already incremented.

Crazy psuedo example of how the monsters created this for us

// Here's our module.
const module = {
  default: {
    get count() {},
  },
}

// Let's import it, but expose it as wholeFoo.
const wholeFoo = {}

// Copy all the exports.  All I see is default.
wholeFoo.default = module.default

// What about backward compatibility?
// Umm... let's copy over everything in default, too.
Object.keys(wholeFoodefault).forEach(key => {

  // Hey, you touched my getter!
  wholeFoo[key] = module[key]
})

Recap

  • import * as results in duplicate props, copying those in default to the top-level.
  • When copying props, getters will be invoked.
  • Copying props breaks the reference, so the top-level is the first value, inside default is the getter.

If you want to dig into this, feel free.

Before we go

Let’s beat this horse for just one more minute, to be sure. How does this work?

import { default as defaultAlias } from './foo'
import { count as countAlias } from './foo'

console.log(defaultAlias.count)
console.log(defaultAlias.count)
console.log(defaultAlias.count)

console.log(countAlias)
console.log(countAlias)
console.log(countAlias)

Any guesses?

I’ll give you a hint. This is false.

defaultAlias.count === countAlias

exports