Skip to content

mod_02 - ES Modules

ES Modules (ESM) are the official JavaScript module system standardized by ECMAScript
CommonJS was a pragmatic hack that worked great for Node but never made it into the browser. ESM fixes everything: static analysis for dead-code elimination , top-level await , named exports that tree-shake cleanly. Node has supported ESM since v12 and it's the default for new packages. If you're starting a Node project in 2026 , use ESM unless you have a specific reason to use CommonJS

enabling ESM

Two ways to tell Node your code uses ES modules:

.mjs extension

Files ending in .mjs are always treated as ES modules

// app.mjs - always ESM , regardless of package.json
import fs from 'fs'

"type": "module" in package.json

Set the type field. All .js files in the package become ESM

{
  "name": "my-app",
  "type": "module",
  "version": "1.0.0"
}
// app.js - ESM because package.json says "type": "module"
import fs from 'fs'

Files ending in .cjs are always CommonJS , even in an ESM package. This is the escape hatch for using CJS-only packages or configuring tools that don't support ESM

import/export syntax

named exports

Zero or more named exports per module

// utils.js
export const PI = 3.14159
export function square(x) { return x * x }
export class Calculator {
  add(a, b) { return a + b }
}

// Import specific names
import { PI, square, Calculator } from './utils.js'
console.log(square(PI)) // 9.869...

// Import all as namespace
import * as utils from './utils.js'
console.log(utils.PI, utils.square(2))

default exports

One default export per module

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

// Import - any name you want
import myLogger from './logger.js'
myLogger('system started')

// Combine default with named exports
import log, { LogLevel, LogFormat } from './logger.js'

mixed exports

Default + named in the same module

// db.js
export default class Database {
  constructor(url) { this.url = url }
}

export function connect(url) { return new Database(url) }
export const DB_URL = 'postgres://localhost/mydb'

// import all together
import Database, { connect, DB_URL } from './db.js'

// or rename on import
import { connect as createConnection } from './db.js'

differences from CommonJS

Static analysis

ESM imports are static - they can't be conditional or dynamic (without import())

// BAD - ESM imports must be at the top level
if (process.env.NODE_ENV === 'test') {
  import { mockDb } from './mock-db.js' // SyntaxError
}

// GOOD - use dynamic import for conditional loading
const db = process.env.NODE_ENV === 'test'
  ? await import('./mock-db.js')
  : await import('./real-db.js')

Strict mode by default

ESM modules are always in strict mode. No 'use strict' directive needed

// No more accidental globals
mistypedVariable = 'hello' // ReferenceError in ESM (fine in sloppy CJS)

Top-level await

ESM supports await at the module's top level without wrapping in an async function

// config.mjs
import { readFile } from 'fs/promises'

const config = JSON.parse(await readFile('./config.json', 'utf8'))
export default config

// importing module waits for config to load
import config from './config.mjs'
console.log(config.host) // guaranteed to have the value

No __filename or __dirname

ESM doesn't inject __filename or __dirname. Use import.meta.url instead

// ESM equivalent of __dirname
import { fileURLToPath } from 'url'
import { dirname } from 'path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

// Or use the newer approach:
import { resolve } from 'path'
const currentDir = resolve('.')

// import.meta.url is the file:// URL of the current module
console.log(import.meta.url)
// 'file:///home/user/app/utils.js'

No require.resolve()

Use import.meta.resolve() instead (experimental in Node 20+)

// ESM equivalent of require.resolve()
const resolved = await import.meta.resolve('./helper.js')
console.log(resolved) // 'file:///home/user/app/helper.js'

CJS/ESM interop

Importing CJS from ESM

CommonJS modules can be imported from ESM - they import the module.exports as the default export

// cjs-module.js (CommonJS)
module.exports = { hello: 'world', count: 42 }

// esm-importer.mjs (ESM)
import cjsModule from './cjs-module.js'
console.log(cjsModule) // { hello: 'world', count: 42 }

// Named imports from CJS work in most cases
import { hello } from './cjs-module.js'
console.log(hello) // 'world'

Named imports from CJS are a best-effort heuristic based on static analysis of the CJS file. It works for simple cases but can fail for dynamic exports or computed property names. When in doubt , use default import

Importing ESM from CJS

You cannot require() an ES module - use dynamic import() instead

// esm-module.mjs
export const data = { value: 'from ESM' }
export default function helper() { return 'default' }

// cjs-consumer.cjs
async function loadEsm() {
  const esmModule = await import('./esm-module.mjs')
  console.log(esmModule.data)         // { value: 'from ESM' }
  console.log(esmModule.default())    // 'default'
}

loadEsm().catch(console.error)

Dynamic import() returns a Promise and can be used anywhere - not just top-level. This is the bridge between CJS and ESM

package.json exports for dual packages

If you're publishing a package that supports both CJS and ESM:

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.cjs",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

This tells Node: if the consumer uses import, serve the ESM file. If they use require(), serve the CJS file. Build tools like tsc , rollup , and esbuild can generate both formats from the same source

security implications

Static analysis works both ways

ESM's static structure enables tree-shaking but also means import errors are detectable at parse time - not runtime. A typo in an import path is caught immediately when the module graph loads

import { nonExistent } from './utils.js' // SyntaxError - detected at module parse time

This is actually a security advantage: malicious injected code that tries to import non-existent modules fails loudly instead of silently returning undefined

Dynamic import() is a code execution vector

import() is a function that returns a Promise. If you pass user-controlled strings to it , you're allowing the user to load arbitrary modules

// BAD - RCE via dynamic import
app.get('/load/:plugin', async (req, res) => {
  const plugin = await import(`./plugins/${req.params.plugin}.js`)
  res.json(plugin.default())
})
// GET /load/../../../etc/passwd -> file reads
// GET /load/%00 -> null byte injection

// GOOD - validate against whitelist
const PLUGIN_MAP = {
  auth: './plugins/auth.js',
  logger: './plugins/logger.js',
}

app.get('/load/:name', async (req, res) => {
  const pluginPath = PLUGIN_MAP[req.params.name]
  if (!pluginPath) return res.status(404).json({ error: 'unknown plugin' })
  const plugin = await import(pluginPath)
  res.json(plugin.default())
})

Module specifier mapping (import maps)

Browsers and Node support "imports" in package.json to remap module specifiers

{
  "imports": {
    "#utils": "./src/utils.js",
    "#utils/*": "./src/utils/*.js"
  }
}
import { formatDate } from '#utils'  // not a relative path

This is fine for your own code. But if you allow user-controlled import specifier prefixes , an attacker could remap system modules to malicious implementations. Validate all dynamic import paths

summary

  • Use .mjs or "type": "module" in package.json to enable ESM
  • Named exports: export const X | Default exports: export default X
  • ESM imports are static - use import() for conditional/dynamic loading
  • ESM is strict mode by default , supports top-level await , has no __dirname
  • Import CJS from ESM with standard import syntax
  • Import ESM from CJS with dynamic import() only
  • Publish dual format packages with exports field in package.json
  • Never pass user input to import() - validate against a whitelist

prerequisites

mod_01_modules.md - CommonJS module system

next -> mod_03_npm.md