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
.mjsor"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
exportsfield 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