Skip to content
C Codeloom
JavaScript

JavaScript ES Modules: import and export

Named exports, default exports, re-exports, dynamic import, and what actually makes a file a module — the modern JavaScript module system explained.

·8 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • The difference between named and default exports
  • How to re-export from another module
  • When to use dynamic import()
  • What makes a file a module (type=module, .mjs)
  • How tree-shaking interacts with your export style

Prerequisites

  • Comfortable with modern JavaScript — see async/await and basic ES6 syntax

For most of JavaScript’s history, splitting code across files meant globals, IIFEs, AMD, or CommonJS. ES Modules — the version baked into the language itself since 2015 — finally unified that mess. Today they run natively in browsers, in Node, in Deno, and in every bundler. This post is a tour of the syntax and the rules behind it.

Named exports

A named export exposes a binding by name. You can have as many as you want per file:

// math.js
export const PI = 3.14159;

export function square(n) {
  return n * n;
}

export class Circle {
  constructor(r) { this.r = r; }
  area() { return PI * square(this.r); }
}

Import them by the same name, in braces:

// app.js
import { PI, square, Circle } from './math.js';

console.log(square(4));            // 16
console.log(new Circle(2).area()); // ~12.56

You can rename on import:

import { square as sq } from './math.js';
console.log(sq(5));    // 25

Or export everything you’ve already declared, in one statement at the bottom:

// math.js
const PI = 3.14159;
function square(n) { return n * n; }

export { PI, square };

Default exports

A default export is the single “main” thing a module exports. There can be at most one per file:

// logger.js
export default function log(message) {
  console.log(`[${new Date().toISOString()}] ${message}`);
}

Import without braces, and name it whatever you want at the call site:

// app.js
import log from './logger.js';
import myLogger from './logger.js';   // same thing, different local name

log('boot complete');

You can mix one default with any number of named exports:

// http.js
export default function request(url) { /* ... */ }
export function get(url) { /* ... */ }
export function post(url, body) { /* ... */ }
import request, { get, post } from './http.js';

Named vs default — which to use

A common debate. The pragmatic guidance:

  • Prefer named exports for libraries and shared modules. The name at the import site matches the name at the definition site, so search and refactor work cleanly.
  • Use default exports when the module truly represents a single thing — a React component, a configuration object, a class.

Default exports trip up tooling. Auto-import in editors guesses a name; if two files default-export different things called Button, the editor cannot disambiguate. Named exports never have that problem.

Re-exports

To collect modules under one entry point, re-export from a barrel file:

// components/index.js
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Modal } from './Modal.js';

// also re-export a default as named
export { default as Card } from './Card.js';

// or re-export everything in one go
export * from './forms.js';

Consumers import from the barrel:

import { Button, Input, Card } from './components/index.js';

Barrels are convenient but watch out: they can defeat tree-shaking if a bundler cannot prove the unused re-exports are side-effect-free. More on that below.

Try it yourself. Create utils/string.js with named exports capitalize, slugify, and truncate. Create utils/index.js that re-exports all three. From app.js, import them via the barrel. Then refactor to import directly from utils/string.js and confirm both forms work identically.

Dynamic import

import as a statement is static — the module is resolved when the file is parsed, and the bindings are hoisted. Sometimes you want a module loaded on demand: a heavy chart library only when the user opens the dashboard, a polyfill only for old browsers.

import() as an expression returns a Promise that resolves to the module’s namespace object:

// app.js
async function showChart() {
  const { Chart } = await import('./chart.js');
  new Chart('#root').render();
}

document.querySelector('#open-chart').addEventListener('click', showChart);

Default exports live on .default:

const mod = await import('./logger.js');
mod.default('hello');     // the default export

Bundlers like Vite, webpack, and Rollup treat import() as a code-splitting boundary — the chunk is emitted as a separate file and only fetched when called. We cover the async side of this in async/await.

What makes a file a module

A .js file is treated as a module — rather than a classic script — in one of three ways:

  1. In the browser, with <script type="module" src="app.js">
  2. In Node, when the file ends in .mjs, or when the nearest package.json contains "type": "module"
  3. In a bundler, almost always — bundlers treat input files as modules by default

The differences from a classic script are not cosmetic:

  • Modules run in strict mode automatically
  • Top-level variables are module-scoped, not global
  • this at the top level is undefined, not the global object
  • import and export are only legal in modules
  • Top-level await works in modules

If you see SyntaxError: Cannot use import statement outside a module, you have a script where you wanted a module. Add "type": "module" to package.json (Node) or type="module" to the script tag (browser).

CommonJS vs ES Modules

Node also supports the older CommonJS system: require() and module.exports. The two systems mostly interoperate, but with sharp edges:

// CommonJS
const fs = require('fs');
module.exports = { hello: () => 'hi' };

// ES Modules
import fs from 'node:fs';
export const hello = () => 'hi';

Rules of thumb in modern Node:

  • New code: prefer ES Modules. Set "type": "module" in package.json.
  • You can import a CommonJS package from an ES Module — its module.exports becomes the default import.
  • You cannot use require() inside an ES Module without createRequire. Use dynamic import() instead.

Tree-shaking

Bundlers analyse your imports statically and drop code that no one imports. This is called tree-shaking, and it only works when the bundler can be certain a binding is unused.

Two practices that help:

// Good — bundler sees you only need `capitalize`
import { capitalize } from './utils/string.js';

// Worse — pulls the entire module namespace
import * as str from './utils/string.js';
str.capitalize('hi');

Side effects defeat tree-shaking. If string.js does anything observable at import time — calling a function, attaching globals — the bundler must include the whole file. Keep modules side-effect-free: declare functions and constants, export them, do nothing else at the top level. You can advertise this with "sideEffects": false in package.json.

Try it yourself. Set up a tiny project with Vite (npm create vite@latest). Create utils.js with five named exports. Import one of them in main.js. Run npm run build and inspect dist/assets/*.js — confirm only the function you used appears in the output.

A small worked example

A library with named exports, a default, and a barrel:

// src/format/number.js
export function currency(n, code = 'USD') {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(n);
}

export function percent(n) {
  return new Intl.NumberFormat('en-US', { style: 'percent' }).format(n);
}
// src/format/date.js
export default function formatDate(d, locale = 'en-US') {
  return new Intl.DateTimeFormat(locale).format(d);
}
// src/format/index.js  (the barrel)
export * from './number.js';
export { default as formatDate } from './date.js';
// app.js
import { currency, percent, formatDate } from './src/format/index.js';

console.log(currency(1299));            // $1,299.00
console.log(percent(0.42));             // 42%
console.log(formatDate(new Date()));    // 6/16/2026

Every binding is named at the consumer site. Tree-shaking works. New formatters can join format/ without touching app.js.

Common pitfalls

  • Forgetting the .js extension. In native ES Modules (Node, browsers), the extension is required. Bundlers often hide this.
  • Circular imports. ES Modules handle cycles by giving you a binding that may be undefined mid-evaluation. Refactor to break the cycle.
  • Mixing module.exports and export. Pick one per file. They do not combine.
  • Re-exporting everything from a barrel that has side effects. Either remove the side effects or skip the barrel for that file.

Recap

You now know:

  • Named exports expose any number of bindings by name
  • A file may have at most one default export
  • import { x } from './m.js' is static; import('./m.js') is dynamic and async
  • A .mjs file or "type": "module" in package.json makes Node treat a file as a module
  • Tree-shaking depends on static, side-effect-free imports
  • Barrels are convenient but can mask unused-export removal

Next steps

Modules organise behaviour. Types organise meaning. The next two posts step into TypeScript’s deepest corner — conditional and mapped types — and show how the language’s most powerful utilities are built from a few small rules.

→ Next: TypeScript Conditional Types Explained

Questions or feedback? Email codeloomdevv@gmail.com.