Course outline · 0% complete

0/27 lessons0%

Course overview →

import and export

lesson 8-3 · ~8 min · 22/27

ES modules in two sentences

Modules exist because the old world was misery.

Every script file shared one global namespace, so two libraries defining utils silently overwrote each other, and load order was a nightly bug.

Modules fix both problems. A module is a file with its own top-level scope, so nothing leaks out unless you export it, and nothing comes in unless you import it.

There are two kinds of exports.

// math.js
export const PI = 3.14159;
export function area(r) {
  return PI * r * r;
}
export default function describe() {
  return "math helpers";
}

Named exports can appear many times per file, and there is at most one default export.

// app.js
import describe, { PI, area as circleArea } from "./math.js";

Named imports use braces and must match the exported names, with as available to rename. The default import has no braces, and you pick any name for it.

That asymmetry is the source of most import bugs. The braces are not decoration, they select between two different export kinds.

Three facts interviewers probe

1. Modules run once.

The first import executes the file, and every later import reuses the same result. Two files importing the same module share one copy of its state, which makes a module an app-wide singleton.

That is the module pattern from lesson 1-3 built into the language, and it is why config objects, database clients, and caches usually live at module scope.

2. Module code is strict mode automatically.

So a plain function call has this === undefined, exactly as promised in lesson 2-1. Nothing opts in and nothing can opt out.

3. Named imports are live bindings.

They are connected views of the exported variable rather than copies made at import time. If the module reassigns an exported let, importers see the new value.

Two smaller facts round it out. Imports are hoisted, so an import at the bottom of a file still runs first, and import declarations must be at the top level, with await import() covering the dynamic case.

The correct import is import makeCounter from "./counter.js";.

Default imports take no braces and can use any local name, so makeCounter is fine even though the module called it createCounter.

The braces version would look for a named export called createCounter, and since the file only has a default export, the import fails.

In a bundler or with older tooling that failure often shows up as undefined rather than an error, which is why mixing these up is such a classic bug. Calling it then throws undefined is not a function on a completely unrelated line.

A file can have both kinds at once, and then the combined form is import makeCounter, { helper } from "./counter.js".

Because the local name is arbitrary, default exports are worse for searchability. The same function can be imported under five different names across a codebase, which is the practical argument many teams make for named exports only.

The keyword is as.

import { formatDate as fmt } from "./dates.js";

That binds the named export formatDate to the local name fmt, which is handy when the original name is long or collides with something already in your file.

It is the same keyword used to rename in export lists, so export { internalName as publicName } renames on the way out.

Collision is the common reason to reach for it. Two modules both exporting format cannot both be imported plainly, and one of them needs a local alias.

as also builds a namespace import, since import * as dates from "./dates.js" puts every named export on one object and then dates.formatDate is the call.

Note that a namespace import cannot be destructured at the top level in the same statement, and it is a frozen object, so dates.formatDate = ... throws.

It prints once.

A module executes exactly once, on first import, and its exports are cached and shared from then on.

The other four imports get the cached module record without re-running a single line, no matter how far apart in the dependency graph they are.

This run-once behavior is why modules make natural singletons for config, database connections, and caches. Importing a connection pool from five files gives five references to one pool.

It also means module-level side effects are hard to control. A file that opens a socket at import time opens it as soon as anything imports it, in an order you did not choose.

The safer habit is to export a factory or an initializer rather than performing the work at the top level, so the caller decides when it happens.

The cache is keyed by resolved specifier, so "./config.js" and "./config.mjs" would be two different modules with two separate copies of the state, which is a subtle way to end up with two singletons.