Skip to main content

Command Palette

Search for a command to run...

JavaScript Modules: Import and Export Explained

Published
11 min read
JavaScript Modules: Import and Export Explained

By Saurabh Prajapati · Full-Stack Engineer at IBM India Software Lab · WebDev Cohort 2026


The Real Problem: Code Without Boundaries

Before we even talk about modules, let me paint a picture of what life looks like without them.

Imagine you're building a small web app. You have functions for doing math calculations, functions for formatting dates, functions for fetching user data, and functions for rendering things on screen. If all of this lives in one file, a few bad things start happening.

First, naming collisions become a nightmare. If you accidentally name two functions the same thing, one silently overwrites the other. JavaScript won't warn you. It'll just break in mysterious ways.

Second, dependencies become invisible. When everything is global, any function can call any other function. There's no clear map of what depends on what. You change one thing, and something completely unrelated breaks.

Third, reusing code becomes painful. Say you want to use your date-formatting function in another project. You'd have to copy the entire file, including all the unrelated stuff — or carefully extract just that one function.

Modules solve all three of these problems elegantly. Think of a module as a self-contained unit of code — like a toolbox that holds only the tools relevant to one specific job. It hides what's internal and only exposes what other parts of your app actually need.


What Even Is a Module?

In JavaScript, a module is just a file. Seriously — that's it. Any .js file can be a module. What makes it a module rather than a plain script is that it has its own scope.

This is the key mental model to internalize: variables and functions defined in a module are private by default. They don't leak into the global scope. Nothing outside that file can see them unless you explicitly choose to share them.

That explicit sharing is what export is for. And pulling shared things into your file is what import is for.

Let's see this in action.


Exporting: Sharing What You Want Others to Use

Say you have a file called mathUtils.js that handles some common calculations.

// mathUtils.js

// This function is private — only usable inside this file
function square(n) {
  return n * n;
}

// These are exported — other files can use them
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

export const PI = 3.14159;

Notice the square function? It has no export keyword. That means it stays completely private inside mathUtils.js. The outside world doesn't even know it exists. This is intentional — you only expose what others actually need.

You can also export things at the bottom of the file, which a lot of people (including me) find more readable:

// mathUtils.js — alternative export style

function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

const PI = 3.14159;

// Export everything at once at the bottom
export { add, multiply, PI };

Both approaches do exactly the same thing. It's a style preference — but the bottom export style has a nice advantage: you can see at a glance what the module's public API is just by looking at the last line.


Importing: Pulling In What You Need

Now let's say you have a main.js file that wants to use those math utilities.

// main.js

// Destructure exactly what you need from the module
import { add, multiply, PI } from './mathUtils.js';

console.log(add(3, 4));       // 7
console.log(multiply(5, 6));  // 30
console.log(PI);              // 3.14159

The { add, multiply, PI } syntax is called named imports. You're cherry-picking exactly what you need. This is great because:

  • You don't import unnecessary stuff (keeps things lean)

  • It's self-documenting — anyone reading this file knows exactly what it depends on

  • Your editor can autocomplete and catch typos instantly

What if you want to import everything a module exports, all at once? You can do that too, using a namespace import:

// Import everything under a single namespace object
import * as MathUtils from './mathUtils.js';

console.log(MathUtils.add(2, 3));   // 5
console.log(MathUtils.PI);          // 3.14159

I personally use this when I'm importing from a file with lots of exports and I don't want to list them all individually. The tradeoff is that you lose some of that self-documenting clarity — MathUtils.add is a little more verbose than just add.


Default vs Named Exports — What's the Difference?

This is the part that confused me the most at first. Let me break it down clearly.

Named exports are what we've been using so far. A file can have as many named exports as it wants, and each one has a specific name that importers must match.

Default exports are different. Each file can have exactly one default export, and when you import it, you can call it whatever you want.

Here's a classic use case — a React-style component:

// UserCard.js

// The main thing this file does — a default export
export default function UserCard({ name, role }) {
  return `<div>\({name} — \){role}</div>`;
}

// A helper used only in this file — not exported at all
function formatName(name) {
  return name.trim().toUpperCase();
}

Now when you import it:

// You can name it anything — no curly braces needed
import UserCard from './UserCard.js';

// Or even rename it entirely (useful when names clash)
import ProfileWidget from './UserCard.js';

No curly braces. No need to match the original name. Total flexibility on the importer's side.

You can also mix both in the same file, which is very common in libraries:

// theme.js

export const colors = { primary: '#d4762a', text: '#1e1c18' };
export const spacing = { sm: '8px', md: '16px', lg: '32px' };

// The "main" export — what this file is fundamentally about
export default {
  colors,
  spacing,
  fontFamily: 'DM Sans, sans-serif'
};
// Importing both at once
import theme, { colors, spacing } from './theme.js';

The rule of thumb I follow: use a default export when the file represents one primary "thing" (a component, a class, a main function). Use named exports for utility files that provide multiple related tools.


Seeing the Full Picture: A File Dependency Diagram

Let me show you how modules connect in a real app. Imagine you're building a small user management feature.

src/
├── utils/
│   ├── mathUtils.js        (exports: add, multiply, PI)
│   ├── formatUtils.js      (exports: formatDate, formatName)
│   └── validators.js       (exports: isEmail, isRequired)
│
├── components/
│   └── UserCard.js         (default export: UserCard component)
│                           (imports from: formatUtils.js)
│
├── services/
│   └── userService.js      (exports: fetchUser, saveUser)
│                           (imports from: validators.js)
│
└── main.js                 ← entry point
                            (imports from: UserCard.js, userService.js)

See how the dependency flow is clear and one-directional? main.js imports from UserCard.js and userService.js. Those in turn import from utility files. Nothing is tangled. If you need to change how names are formatted, you go to formatUtils.js — and only there.

This is what "separation of concerns" actually looks like in practice.


The Import/Export Flow at a Glance

Here's a simple mental model of how data flows between modules:

┌─────────────────────────────────┐
│          mathUtils.js           │
│                                 │
│  function add(a, b) { ... }     │
│  function multiply(a, b) { .. } │
│  const PI = 3.14159             │
│                                 │
│  export { add, multiply, PI }   │
│         ↓ named exports         │
└──────────┬──────────────────────┘
           │  (flows into)
           ▼
┌─────────────────────────────────┐
│            main.js              │
│                                 │
│  import { add, PI }             │
│    from './mathUtils.js'        │
│                                 │
│  console.log(add(2, 3))  → 5   │
│  console.log(PI)  → 3.14159    │
└─────────────────────────────────┘

The exporting file says: "Here's what I'm willing to share." The importing file says: "Here's exactly what I need." Everything else stays private.


Key Discoveries Along the Way

The ./ at the start of the path matters. When I first tried writing import { add } from 'mathUtils.js', nothing worked. The ./ tells JavaScript you're importing from a relative file path, not an installed package. Without it, JavaScript looks for an npm package named mathUtils.js and obviously can't find it. I wish someone had told me this on day one.

Modules are singletons. If three different files all import from mathUtils.js, JavaScript only loads and runs mathUtils.js once. They all share the same instance. This is actually very useful — it means you can store state in a module and it acts like a shared, controlled global.

Circular imports exist and they're a code smell. If fileA.js imports from fileB.js, and fileB.js imports from fileA.js, you have a circular dependency. JavaScript technically handles this, but it often leads to confusing bugs where something is undefined when you expect it to have a value. If you notice a circle forming, it's usually a sign to extract the shared thing into a third file.

You can rename on import. This one was a pleasant surprise:

// Rename to avoid naming conflicts
import { add as addNumbers, multiply as multiplyNumbers } from './mathUtils.js';

Super useful when two different modules export something with the same name.


Benefits of Modular Code

Once I restructured my old Thitainfo-code project into modules, the improvement was immediate and dramatic.

Maintainability shoots up. Each file has a clear, focused responsibility. When a bug shows up in date formatting, you know exactly which file to open.

Reusability becomes natural. Your validators.js can be dropped into any project. Zero rework required.

Collaboration gets easier. Two developers can work on different modules simultaneously without stepping on each other's code. The boundaries are enforced by the language itself.

Testability improves dramatically. A module with a clear input-output contract is trivially easy to unit test. You import it, call it, check the result. No global state, no hidden dependencies.


What Can You Build With This?

Now that you understand modules, a whole pattern opens up. Here's a quick practical example — a mini utility library for a project:

// lib/string.js
export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}

export function truncate(str, maxLength) {
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength) + '...';
}

export function slugify(str) {
  return str.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
}
// lib/index.js — a "barrel" file that re-exports everything
export * from './string.js';
export * from './math.js';
export * from './validators.js';
// Now in your app, one clean import gives you everything
import { capitalize, slugify, isEmail } from './lib/index.js';

This pattern — where an index.js re-exports from multiple files — is called a barrel export. It's extremely common in professional codebases. It gives your app a single, clean entry point into a whole collection of utilities.


Wrapping Up

Here's what clicked for me after going through all of this: modules aren't a fancy advanced feature. They're just a formalization of something every developer intuitively wants — organized code with clear boundaries.

The export keyword is your way of saying, "This is the contract I'm offering to the outside world." The import keyword is your way of saying, "These are the exact things I'm depending on." Everything else stays hidden, protected, and clean.

If your codebase currently lives in one big file, I'd genuinely encourage you to try splitting just one logical piece out into its own module. Format it, export it, import it back. You'll feel the difference immediately.

And then — like me — you probably won't want to go back.


About the Author

Saurabh Prajapati is a Full-Stack Software Engineer at IBM India Software Lab, where he builds cloud-native, enterprise-level solutions for the Maximo platform. He specializes in GenAI, React, and modern web technologies, and has worked on everything from AI-powered tools and RAG pipelines to scalable web platforms.

When he's not shipping features at IBM, he's writing about the things he's actively learning — because the best way to understand something is to explain it.