Frontend Master

JavaScript Curriculum

Splitting Code into Files
+45 XP

Splitting Code into Files

medium
~20 min·45 XP

The Nexus codebase has grown. Everything is in one file: utilities, API calls, UI logic, validation — 3,000 lines. Nobody can find anything. A new engineer joins and doesn't know where to start. Modules are the solution: one responsibility per file, explicit contracts for what each file shares, and a bundler that ships only what the user's browser actually needs.

Why modules exist

In the early days of JavaScript, all code shared a single global scope. Every variable, every function — all visible everywhere, all potentially colliding. A library called utils.js might overwrite a function you defined. A third-party script might corrupt your user variable.

Modules solve this: each file gets its own scope. Nothing is visible outside unless you explicitly export it. Nothing is usable inside unless you explicitly import it. The dependencies are visible, auditable, and under your control.

Compare the two module systems:

exporting

// math.js
export const add      = (a, b) => a + b
export const subtract = (a, b) => a - b
export const PI       = 3.14159

// Default export — one per file:
export default function multiply(a, b) {
  return a * b
}

importing

// app.js
import multiply, { add, subtract, PI } from './math.js'

// Rename on import:
import { add as sum } from './math.js'

// Import everything as namespace:
import * as math from './math.js'
math.add(2, 3)

// Dynamic import — async, lazy loads:
const { add } = await import('./math.js')

Asynchronous — non-blocking, safe for browsers

Must be at top level — not inside if blocks or functions (except dynamic)

Static structure — bundlers can tree-shake unused exports away

Native in all modern browsers with type="module" on <script>

✓ The universal standard. Works in browsers natively, in Node.js 12+, in all bundlers. Static — enables tree-shaking.

CommonJS (require) predates the language standard and works synchronously — it was designed for Node.js servers where file reads are fast. ES Modules (import) are the official standard: static, asynchronous, and natively understood by browsers and all modern bundlers.


Named exports, default exports, and everything in between

A single file can export multiple things. The five patterns cover every real-world case:

import / export patterns — pick a style

Any number per file. Import by exact name (or rename).

utils/math.js — exporting

export const add  = (a, b) => a + b
export const mul  = (a, b) => a * b
export const PI   = 3.14159

app.js — importing

import { add, mul, PI } from './utils/math.js'

add(2, 3)   // 5
mul(4, 5)   // 20
PI          // 3.14159

The key distinction:

Named exports — export by name, import by the same name (or renamed with as). Any number per file. The source of truth for a file's public API.

Default export — one per file, imported with any name the caller chooses. Best for the primary thing a file provides — a class, a component, a main function.

js
// math.js — real-world module structure export const PI = 3.14159 // named constant export const add = (a, b) => a + b // named function export const mul = (a, b) => a * b // named function export default class MathEngine { // default export — the main thing constructor(precision = 2) { this.precision = precision } round(n) { return +n.toFixed(this.precision) } }
js
// Using it import MathEngine, { PI, add, mul } from './math.js' // or rename to avoid collisions: import { add as sum, mul as product } from './math.js'
💡Default exports make renaming easy — at a cost
Default exports let each caller choose the name, which is flexible. But that means grep and "find all references" tools can't reliably find all usages. Named exports are always found by their exact name. In large codebases, named exports are generally preferred.

Tree-shaking — only shipping what you use

Modern bundlers (Webpack, Vite, esbuild, Rollup) analyse your import statements at build time. Because ES module imports are static — they can't change at runtime — the bundler can determine exactly which exports are actually used. Everything else is eliminated from the final bundle.

tree-shaking — unused exports eliminated at build time

add

120B

✓ kept

subtract

115B

~ kept (no shaking)

multiply

130B

✓ kept

divide

118B

~ kept (no shaking)

PI

24B

✓ kept

GOLDEN

24B

~ kept (no shaking)

formatCurrency

340B

✓ kept

formatDate

290B

~ kept (no shaking)

bundle size
1161B1161B

Tree-shaking only works with ES modules. require() is dynamic — a bundler can't know at build time which exports will be used, so it must include everything. This is one of the primary reasons CommonJS is being replaced.


Module resolution — how imports are found

js
// Relative path — file on disk import { add } from './utils/math.js' // same directory import { log } from '../lib/logger.js' // parent directory // Bare specifier — resolved by bundler/runtime to node_modules import React from 'react' import { useState } from 'react' // URL — browser only import confetti from 'https://cdn.skypack.dev/canvas-confetti' // Dynamic import — loaded on demand const { add } = await import('./utils/math.js')
⚠️Browser ESM requires the .js extension
When importing in native browser ESM (without a bundler), you must include the .js extension. Bundlers like Vite/Webpack resolve extensions automatically. In Node.js, the same rule applies with --experimental-vm-modules or when using "type": "module" in package.json.

Re-exporting — building a public API

A common pattern: an index.js file that re-exports from multiple sub-modules, giving consumers a single clean import point:

js
// utils/index.js — aggregates and re-exports export { add, subtract, PI } from './math.js' export { formatDate, parseDate } from './dates.js' export { default as Logger } from './logger.js' export * from './validators.js' // re-export everything // Consumers import from one place: import { add, formatDate, Logger } from './utils' // instead of: import { add } from './utils/math.js' import { formatDate } from './utils/dates.js' import Logger from './utils/logger.js'

Your challenge

Simulate a three-export module: a named formatter function, a named constant, and a default validator. The import statement should use both named and default import syntax together. The challenge tests that you can read and write the two export styles and combine them in a single import line.

Challenge

Create a 'module' called userUtils. Export a named function called formatUser(user) that returns the string user.name + ' (' + user.role + ')'. Export a named constant called MAX_XP with value 10000. Export a default function called isAdmin(user) that returns user.role === 'admin'. Then write the import statement that gets all three into scope and log the result of each.

modulesimportexportnamed-exportdefault-exporttree-shakingESMCommonJS
Splitting Code into Files | Nexus Learn