Skip to main content

Command Palette

Search for a command to run...

JavaScript ES Modules: Import & Export

Master ES module syntax, default vs named exports, module scope, and real-world project structure — no bundlers required.

Updated
JavaScript ES Modules: Import & Export
M

👋 Hey, I'm Mohd Kaif – a student documenting my journey through code. I write about what I'm learning in real-time – the wins, the struggles, and the "aha!" moments. From JavaScript and React to backend systems with Node.js, databases, DevOps, TypeScript, and AI integrations. This blog is my public learning journal: honest, evolving, and always exploring. If you're curious about any of these topics, let's learn and build together!

You know that feeling when you open a JavaScript file and it's 800 lines long — and somehow, it keeps growing?

You scroll through it looking for one function, but everything is tangled together. Variables share the same names. A change in one place breaks something three hundred lines away. You know something is wrong, but you're not quite sure how to fix it.

Sound familiar? You might be asking yourself:

  • "Why does my code feel impossible to navigate as it grows?"

  • "How do other developers keep large projects clean and organized?"

  • "What even are JavaScript modules, and do I actually need them?"

  • "Is there a better way to structure my code without learning a whole new framework?"

The problem isn't that you're a bad developer. It's that nobody handed you the right mental model early enough. JavaScript modules are one of the most fundamental tools in modern development — and once they click, you'll wonder how you ever coded without them.


✅ What You'll Learn

  • Why a single-file approach breaks down and what replaces it

  • How to export functions, values, and objects from any JavaScript file

  • How to import exactly what you need, wherever you need it

  • When to use default exports vs. named exports (and why it matters)

  • Why modular code makes your projects easier to test, scale, and collaborate on

  • What to build next after mastering modules

No bundlers. No frameworks. No prerequisites — just clean, modern JavaScript.


The Problem With "One Big File"

Before modules existed, developers had two choices: dump everything into one file, or manually load dozens of <script> tags in the right order and hope nothing conflicted.

Both approaches had the same core problem — everything shared the same global scope.

Imagine two developers working on the same project. One writes a function called formatData(). The other writes a completely different formatData() for a different purpose. The second definition silently overwrites the first. No error. No warning. Just a bug that's brutal to track down.

This is called global scope pollution, and it scales terribly. Here's what that world looked like:

// ❌ The old way — everything crammed into one file or global scope

var userName = "Alice";
var userName = "Bob"; // silently overwrites the first one

function formatData(data) { /* version 1 */ }
function formatData(data) { /* version 2 — kills version 1 */ }

The more code you added, the more fragile everything became. JavaScript modules were designed specifically to eliminate this problem.


What Exactly Is a JavaScript Module?

A module is just a JavaScript file that has its own private scope. It doesn't automatically share anything with the outside world. Instead, it explicitly chooses what to expose.

Think of it like this:

A module is a self-contained workshop. It has its own tools and workbench. When you need something from it, you knock on the door and ask for it specifically — it doesn't throw everything through the window at you.

Each module:

  • Has its own scope — variables inside don't leak out

  • Exports only what it wants to share — via the export keyword

  • Imports only what it needs — via the import keyword

That's the whole model. Everything else is just syntax.


Exporting: Sharing What You Build

To make something available to other files, you mark it with export. There are two flavors worth understanding well: named exports and default exports.

Named Exports

Named exports let you share multiple values from a single file. Each exported item keeps its original name.

// utils/currency.js

export const formatCurrency = (amount, currency = "USD") => {
  return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
};

export const parseCurrency = (str) => {
  return parseFloat(str.replace(/[^0-9.-]+/g, ""));
};

export const SUPPORTED_CURRENCIES = ["USD", "EUR", "GBP", "JPY"];

Notice how this file does one focused job: it handles currency formatting. That focus is intentional and is the heart of good modular design.

You can also export after the declaration if you prefer to define everything first:

const formatCurrency = (amount) => { /* ... */ };
const parseCurrency = (str) => { /* ... */ };

// Export at the bottom — great for readability in longer files
export { formatCurrency, parseCurrency };

Default Exports

A module can have exactly one default export. It's meant for the primary thing a file represents — the main event.

// services/authService.js

export default class AuthService {
  login(email, password) { /* ... */ }
  logout() { /* ... */ }
  getCurrentUser() { /* ... */ }
}

Rule of thumb: If your file has one main concept (a class, a primary function, a React component), use a default export. If it's a toolbox of utilities, use named exports.


Importing: Consuming What Others Build

Once something is exported, you pull it into another file with import.

Importing Named Exports

// app.js
import { formatCurrency, SUPPORTED_CURRENCIES } from './utils/currency.js';

console.log(formatCurrency(1999.99));       // "$1,999.99"
console.log(SUPPORTED_CURRENCIES);          // ["USD", "EUR", "GBP", "JPY"]

You use curly braces and the exact name that was exported. If the names don't match, the import fails.

Renaming on Import

Sometimes a name conflicts with something in the current file, or you just want something more descriptive:

import { formatCurrency as toCurrency } from './utils/currency.js';

console.log(toCurrency(49.99)); // "$49.99"

Importing a Default Export

No curly braces. You choose the name — any name works.

import AuthService from './services/authService.js';
import Auth from './services/authService.js'; // also valid

This flexibility is convenient, but it can hurt readability in large teams. Everyone calling the same import a different name makes code harder to search and review. More on this in the mistakes section.

Importing Everything at Once

import * as CurrencyUtils from './utils/currency.js';

CurrencyUtils.formatCurrency(500);
CurrencyUtils.parseCurrency("$1,200.00");

The namespace import (* as) is useful when you're using many exports from the same file, but avoid it when you only need one or two — it imports more than necessary.


🛠️ Exercise 1: Practice Exporting

Create a file called stringUtils.js and export the following three functions using named exports:

  1. capitalize(str) — returns the string with the first letter uppercased

  2. truncate(str, maxLength) — truncates a string and adds "..." if it exceeds maxLength

  3. slugify(str) — converts "Hello World" into "hello-world"

Then create a second file app.js and import all three. Call each one with a test value and log the result.

This should take about 5 minutes and will cement the export/import syntax better than reading it twice.


Default vs. Named: The Comparison That Actually Matters

This is the source of more confusion than almost anything else in modules. Here's the definitive breakdown:

Named Export Default Export
How many per file Unlimited Exactly one
Import syntax import { name } import anything
Name enforced? ✅ Yes ❌ No
Best for Utility collections Single primary concept
Refactoring safety Higher (name is a contract) Lower (any name is valid)

The hidden danger of default exports in large codebases is that they're hard to track. If you rename the default export inside the file, no import statement breaks — they all still work, because they're just importing "whatever the default is." Named exports, by contrast, act as an explicit contract between the file and its consumers.


How Modules Actually Work Under the Hood

You don't need to understand the engine internals to use modules effectively, but one concept is worth knowing: modules are statically analyzed.

When your JavaScript runtime encounters an import statement, it doesn't wait until that line of code executes to figure out what's being imported. It analyzes the entire dependency graph before running a single line.

This has a powerful implication: circular dependencies can silently produce undefined values.

// ❌ Circular dependency — avoid this

// a.js
import { b } from './b.js';
export const a = `I depend on ${b}`;

// b.js
import { a } from './a.js';
export const b = `I depend on ${a}`; // 'a' is undefined here at load time

When b.js is being loaded, a.js hasn't finished executing yet — so a is undefined. No error is thrown. The bug just silently lives in your output.

How to fix it: Extract the shared logic into a third file that both a.js and b.js import from. Break the circle.


A Real-World Project Structure

Here's what modular thinking looks like in a small but realistic project — a dashboard that fetches and displays user data:

dashboard-app/

  • 📁 utils/

    • formatters.js — pure formatting functions (dates, currency, strings)

    • validators.js — input validation helpers

  • 📁 services/

    • apiService.js — all fetch/HTTP logic lives here

    • authService.js — login, logout, and session management

  • 📁 components/

    • UserCard.js — UI rendering logic
  • app.js — the entry point; imports from everything above and wires it together

// utils/formatters.js
export const formatDate = (dateStr) =>
  new Date(dateStr).toLocaleDateString("en-US", { dateStyle: "medium" });

export const formatCurrency = (amount) =>
  new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
// services/apiService.js
const BASE_URL = "https://api.example.com";

export const getUser = async (id) => {
  const res = await fetch(`\({BASE_URL}/users/\){id}`);
  if (!res.ok) throw new Error("Failed to fetch user");
  return res.json();
};

export const getUserOrders = async (userId) => {
  const res = await fetch(`\({BASE_URL}/users/\){userId}/orders`);
  return res.json();
};
// app.js
import { getUser, getUserOrders } from './services/apiService.js';
import { formatDate, formatCurrency } from './utils/formatters.js';

const userId = 42;

const [user, orders] = await Promise.all([
  getUser(userId),
  getUserOrders(userId),
]);

console.log(`Welcome, ${user.name}`);
orders.forEach(order => {
  console.log(`\({formatDate(order.date)} — \){formatCurrency(order.total)}`);
});

Notice what app.js does not contain: fetch logic, date formatting logic, or currency formatting logic. It's the conductor. Each module is the musician.


🛠️ Exercise 2: Refactor a Messy File

Take this single bloated file and split it into at least three separate modules:

// ❌ everything.js — refactor this

const API_KEY = "abc123";
const BASE_URL = "https://api.example.com";

function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function slugify(str) {
  return str.toLowerCase().replace(/\s+/g, "-");
}

async function fetchProduct(id) {
  const res = await fetch(`\({BASE_URL}/products/\){id}?key=${API_KEY}`);
  return res.json();
}

async function fetchUser(id) {
  const res = await fetch(`\({BASE_URL}/users/\){id}?key=${API_KEY}`);
  return res.json();
}

class CartManager {
  constructor() { this.items = []; }
  addItem(item) { this.items.push(item); }
  getTotal() { return this.items.reduce((sum, i) => sum + i.price, 0); }
}

Goal: Create utils/stringUtils.js, services/api.js, and CartManager.js. Then wire them together in a clean app.js.


Common Mistakes to Avoid

1. Using curly braces on a default import

// logger.js
export default function log(msg) { console.log(msg); }

// ❌ Wrong
import { log } from './logger.js'; // undefined — log isn't a named export

// ✅ Correct
import log from './logger.js';

2. Forgetting the .js extension in browser environments

Bundlers like Webpack or Vite let you omit extensions. Native browser modules do not.

import { formatDate } from './utils/formatters';    // ❌ fails in browser
import { formatDate } from './utils/formatters.js'; // ✅

3. Overusing default exports across a team

import something from './data/processor.js';

What is something? Without opening the file, you have no idea. Named exports make your codebase self-documenting. Prefer them unless there's a strong reason for a default.

4. Importing everything with * as when you need one thing

import * as Utils from './utils/formatters.js'; // loads everything
Utils.formatDate(date);                          // only used one function

Just import what you need. It's cleaner and helps tools like bundlers tree-shake unused code.


Why Modules Matter Beyond Syntax

Modules aren't just a cleaner way to write code. They fundamentally change how you build, test, and collaborate on software.

Maintainability — Small, focused files are easier to read and reason about. When a bug appears in date formatting, you know exactly which file to open.

Testability — Isolated modules can be unit tested independently. You don't need to spin up your entire app to test a formatCurrency function.

Team collaboration — Modules create natural ownership. One developer owns authService.js. Another owns apiService.js. Merge conflicts become rare.

Scalability — You can add new features by adding new modules without touching existing ones. This is the open/closed principle in practice.

Tree shaking — Modern bundlers can automatically remove unused exports from your production bundle. This only works because modules are statically analyzable — another hidden benefit of the design.


🛠️ Exercise 3: Build a Mini Module System

Build a small but complete module structure for a to-do list app. You don't need a UI — just the logic layer.

Your module structure should include:

  1. utils/dateUtils.js — exports formatDate and isOverdue(dueDate)

  2. models/todo.js — exports a createTodo(title, dueDate) factory function

  3. services/todoService.js — exports addTodo, completeTodo, getAll, and getPending

  4. app.js — imports from all three and runs a small simulation

Bonus: Make todoService.js import from both models/todo.js and utils/dateUtils.js.


What to Learn Next

You now have a solid foundation in JavaScript modules. Here's where to go from here, in order:

1. ES Module Interop with CommonJS Node.js still uses require() / module.exports in many projects. Understanding how ES Modules and CommonJS coexist will unblock you in real-world Node environments. Look for: "type": "module" in package.json and the .mjs file extension.

2. Dynamic Imports Everything you've learned so far uses static imports — they're resolved at load time. Dynamic imports (import('./module.js').then(...)) let you load modules on demand, which is fundamental to code splitting and performance optimization.

3. Module Bundlers (Vite or esbuild) Once you're comfortable with native modules, pick up Vite. It builds on native ES Modules and gives you hot module replacement, bundling, and tree shaking with almost zero config. It's where the industry has moved.

4. Module Patterns in Frameworks React components, Vue composables, and Angular services are all just modules with conventions layered on top. Now that you understand the foundation, learning any of these frameworks becomes significantly easier.


💬 Got Questions?

Drop a comment below! I'd love to hear how you're structuring your projects, where you got stuck, or what clicked for you reading this.

Here are topics coming up in future articles:

  • Dynamic Imports & Code Splitting: How to load JavaScript on demand and dramatically improve page load times

  • CommonJS vs. ES Modules: The full story on require() vs import and how to navigate Node.js projects that use both

  • Barrel Files & Index Exports: The index.js pattern that makes imports cleaner — and when it quietly causes performance problems

  • Tree Shaking Explained: How bundlers use your module structure to remove dead code from production builds


Found this helpful? Share it with a developer friend who's still writing 500-line JavaScript files — you might just change how they code. And if you have questions or spotted something I should clarify, the comments are open.

Zero to Full Stack Developer: From Basics to Production

Part 22 of 50

Complete full-stack web development series from zero to production. Learn HTML, CSS, JavaScript, TypeScript, React, Next.js, Node.js, databases, Docker, AWS, and AI integration. Build real-world projects step-by-step.

Up next

JavaScript Array Flattening Guide

Six approaches, real benchmarks, and the mental model that makes nested data finally make sense.