18.1 ES Module Syntax: import and export
Right, let’s talk about ES Modules. This is the official, standardized way to handle modules in JavaScript, and frankly, it’s about time. If you’ve been wrestling with require and module.exports in CommonJS (and we’ll get to that mess shortly), this is the clean, logical, and frankly superior system you’ve been waiting for. The syntax is purposefully designed to be statically analyzable, which is a fancy way of saying tools (and your brain) can figure out what’s going on before the code runs. This unlocks all sorts of goodies like better bundling, dead code elimination, and reliable tree-shaking.
The core of it boils down to two keywords: export to expose functionality from a file, and import to, well, import it into another.
The Many Ways to export
You have two primary styles of exporting: named and default. A module canβand often shouldβuse both.
Named Exports are for when you have multiple things to sell. You can export them individually:
// π utils.js
export const apiKey = 'supersecretkey';
export function greet(name) { return `Hello, ${name}!`; }
export class Calculator {
add(a, b) { return a + b; }
}
Or, my personal preference for clarity, you can declare everything normally and then export them all in a single, tidy list at the bottom. This acts as a perfect table of contents for your module.
// π utils.js
const apiKey = 'supersecretkey';
function greet(name) { return `Hello, ${name}!`; }
class Calculator {
add(a, b) { return a + b; }
}
// Be explicit about what you're selling
export { apiKey, greet, Calculator };
You can even rename them as you export using as, which is incredibly useful for avoiding naming collisions or just making an internal name more public-friendly.
export { apiKey as SECRET_TOKEN, greet };
Default Exports are the module’s “main” value. You get exactly one per module. It’s the value you get when you just import something from 'module' without the curly braces. Use it for the primary function of a module, a main class, or a configuration object.
// π Logger.js
class Logger {
log(message) {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
}
}
// One per module, please.
export default Logger;
The syntax is flexible; you can export a value directly, but I find it less readable.
// Don't do this. It's legal but confusing.
export default class {
log(message) { ... }
}
The Art of the import
Importing is where you see the payoff. To import named exports, you use those curly braces. The names must match exactly.
// π main.js
import { apiKey, greet, Calculator } from './utils.js';
console.log(apiKey); // 'supersecretkey'
const myCalc = new Calculator();
If the names are terrible or cause a conflict, rename them on the spot with as. This is your best friend.
import { apiKey as TOKEN, greet as hello } from './utils.js';
console.log(TOKEN);
To import a default export, you leave the braces off. You can name it whatever you want.
// These are all equivalent. The name is arbitrary.
import Logger from './Logger.js';
import MySuperLogger from './Logger.js';
import AbsolutelyAnything from './Logger.js';
And of course, you can mix and match in a single line. The default import must come first.
// Import the default export and some named ones
import Logger, { apiKey, greet } from './utils.js';
The Wildcard (*) Import
Sometimes, you just want everything. This is where the namespace import comes in. You dump all of a module’s named exports into a single object. This is fantastic for utility libraries or when you genuinely need to import a ton of stuff.
// π main.js
import * as Utils from './utils.js';
console.log(Utils.apiKey);
const result = Utils.Calculator.add(1, 2);
Utils.greet('Reader');
A crucial point: this Utils object only contains the named exports. It does not include the default export. They are separate. If your module has a default export, you must import it separately.
// If Logger.js had a default export AND named exports
import Logger, * as LoggerUtils from './Logger.js';
The Critical .js Extension and Other Absurdities
Here’s the part I need you to breathe through. In a browser environment, the import specifier is a URL. This means you must include the file extension (.js). It’s not optional. The idea of omitting it was a Node.js-ism that, while convenient, breaks the web standard. So get used to writing from './module.js', not from './module'.
Now, for the truly absurd part: if you’re working in a Node.js project, you have to set "type": "module" in your package.json to opt-in to this modern behavior. Otherwise, it assumes the old CommonJS syntax. It’s a baffling choice that fractures the ecosystem, but here we are. Always set "type": "module" in new projects. Trust me.
The Static Nature and Why It Rules
You can’t conditionally import an ES Module using an if statement. The import declarations must be at the top level of your file. This is by design! That static structure is what allows tools to analyze your dependency graph without running your code. If you need conditional or dynamic loading, you use the import() function, which returns a promise and is a topic for another day.
// This will throw a Syntax Error. Don't do it.
if (userNeedsLogger) {
import Logger from './logger.js'; // β Nope.
}
// This is how you do it.
const LoggerModule = await import('./logger.js'); // β
Yes.
const Logger = LoggerModule.default;