Get Ready, Import!
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.
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.
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
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 indefault
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