JSON modules, dynamic import and top-level await
Import JSON files with import attributes, load modules while the program runs with import(), use top-level await, and find files with import.meta.
What you will learn
- Import a JSON file as a module with a with { type "json" } attribute
- Load a module on demand with import() and handle a missing one
- Use await at the top level of a module
- Locate files next to a module with import.meta.url and import.meta.dirname
On this page
Static import lines are fixed when the file is written and run before anything else. This lesson covers the other ways to bring in data and code: JSON files, modules chosen while the program runs, and the await keyword at the top of a file.
A first look at await
Some operations take time: loading a module, reading a file, waiting on a timer. JavaScript hands back a promise for such an operation, which is a placeholder for a result that is not ready yet. The keyword await pauses the code until the promise has its result, and then gives you that result. A rejected promise makes await throw an error instead.
You may write await in two places: at the top level of an ES module, and inside a function whose definition starts with the keyword async. An async function always returns a promise itself. This is new syntax in the course, and this lesson uses only the simplest forms of it. Promises and async functions get their own lessons in the intermediate level, so for now read await something() as “wait for this, then continue”.
JSON modules
A JSON file can be imported like a module. You must say what kind of file it is with an import attribute, with { type: "json" }. JSON modules are part of ES2025 and run on Node.js 22.
{ "appName": "demo", "port": 8080, "features": ["search", "export"]}import config from "./config.json" with { type: "json" };
console.log(config.appName, config.port);console.log(config.features.includes("export"));
config.port = 9090;const again = await import("./config.json", { with: { type: "json" } });console.log(again.default.port, again.default === config);
try { await import("./config.json");} catch (error) { console.log(error.code);}demo 8080true9090 trueERR_IMPORT_ATTRIBUTE_MISSING- The JSON file’s content is the default export. There are no named exports, so write
import config from "./config.json" with { type: "json" }. await import(...)is the call form ofimport, covered in the next section. Here it loads the file a second time.- Without the attribute the import fails. The last statement in the example imports the file without it, and the error’s
codeisERR_IMPORT_ATTRIBUTE_MISSING. - The module is cached, as every module is. The second import returned the very same object (
again.default === configistrue), and the change toconfig.portis visible through it. Treat imported data as shared. - Older code and tutorials wrote
assert { type: "json" }. That keyword was replaced bywith. Usewith.
For a file that may change while the program runs, or that you want to handle errors for, read it with readFileSync and JSON.parse, as in the JSON lesson.
Dynamic import
import(specifier) is an expression that loads a module when it runs and returns a promise for the module’s namespace object. It works anywhere: in an if, in a function, in a loop. The specifier can be any string, including one built from a variable.
Put the plugins in a folder called plugins, next to the file that loads them. The folder layout is:
run_plugins.mjsplugins/ shout.mjs whisper.mjsconsole.log("shout plugin loaded");
export const name = "shout";
export default function run(text) { return text.toUpperCase() + "!";}console.log("whisper plugin loaded");
export const name = "whisper";
export default function run(text) { return text.toLowerCase() + "...";}run_plugins.mjs goes next to the plugins folder, not inside it. Its import() path, ./plugins/${pluginName}.mjs, starts from the folder where run_plugins.mjs lives.
console.log("start");
async function runPlugin(pluginName, text) { try { const plugin = await import(`./plugins/${pluginName}.mjs`); return plugin.default(text); } catch (error) { if (error.code === "ERR_MODULE_NOT_FOUND") { return `no plugin called ${pluginName}`; } throw error; }}
for (const name of ["shout", "whisper", "shout", "missing"]) { console.log(await runPlugin(name, "Hello"));}startshout plugin loadedHELLO!whisper plugin loadedhello...HELLO!no plugin called missing- The function is marked
asyncso that it can useawait. Calling it gives a promise, and theawaitin the loop waits for the answer. startprints first. Neither plugin ran untilimport()was called for it.await import(...)waits for the module to load and run. The result has the module’s exports, soplugin.defaultis the default export.- The second
shoutdid not printshout plugin loadedagain. A module is loaded and run once, andimport()returns the cached one. - A name with no file makes the promise reject with the error code
ERR_MODULE_NOT_FOUND. The example catches the error, answers for that one case and re-throws anything else. - Specifiers made from user input are dangerous. If
pluginNamecame from a user, they could load any file. Check names against a list first.
Dynamic import suits code that is large or rarely needed, code chosen from a configuration, and plugins. For everything else, use a static import, which is easier to read and check.
Top-level await
The examples above used await outside any function. In an ES module that is allowed. This is top-level await, available since ES2022. The module pauses at the await line, and any module that imports it waits too.
import { readFile } from "node:fs/promises";
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
console.log("before");await pause(20);console.log("after 20 ms");
const file = new URL("./config.json", import.meta.url);const text = await readFile(file, "utf8");console.log(JSON.parse(text).features);beforeafter 20 ms[ 'search', 'export' ]setTimeout asks the runtime to call a function after a delay in milliseconds. The pause helper wraps it in a promise that is ready after that delay, so await pause(20) waits 20 ms. readFile from node:fs/promises is the same idea for a file: it returns a promise, and await gives you the text. Three rules cover most uses:
- Use
awaitonly inside anasyncfunction, or at the top of a module. - It does not work in CommonJS files. The next lesson explains the difference.
- If a promise rejects,
awaitthrows, so wrap it intryandcatchwhen you expect trouble.
Do not use top-level await for work that is slow and not needed to start, because every importer waits for it.
import.meta: where am I?
import.meta is an object with information about the current module.
import { readFileSync } from "node:fs";import { basename, join } from "node:path";import { fileURLToPath } from "node:url";
console.log(import.meta.url.startsWith("file://"));console.log(basename(import.meta.filename));console.log(basename(import.meta.dirname));console.log(fileURLToPath(import.meta.url) === import.meta.filename);
const text = readFileSync(join(import.meta.dirname, "config.json"), "utf8");console.log(JSON.parse(text).appName);
const sibling = new URL("./config.json", import.meta.url);console.log(JSON.parse(readFileSync(sibling, "utf8")).port);truewhere_am_i.mjsdynamictruedemo8080Line 3 of the output is the name of the folder you saved the file in, so yours will differ.
import.meta.urlis the module’s address as afile:URL.import.meta.filenameis the full path of the file, andimport.meta.dirnameis its folder. Both were added in Node.js 20.11 and 21.2, and stopped being experimental in 22.16 and 24.0. They exist only for modules loaded from files.- Files are found relative to the current directory when you use a plain relative path like
"config.json"inreadFileSync. That path changes depending on where the program is started. Building the path fromimport.meta.dirname, or fromnew URL("./config.json", import.meta.url), always finds the file next to the module.
Common pitfalls
- Importing JSON without
with { type: "json" }. It fails withERR_IMPORT_ATTRIBUTE_MISSING. - Using
assertinstead ofwith. The old keyword is not the standard. - Forgetting
awaitonimport(). You get a promise, not the module. - Building import paths from untrusted input. Only load names you expect.
- Using
__dirnameand__filenamein a module. They do not exist in ES modules. Useimport.meta.dirnameandimport.meta.filename. - Expecting an edited JSON file to reload. Modules are cached for the life of the program.
Exercises
Save this file as books.json. Write recent_books.mjs, which imports it as a JSON module and prints the titles of the books published after 1980, then the total number of books.
[ { "title": "Dune", "year": 1965 }, { "title": "Neuromancer", "year": 1984 }, { "title": "Hyperion", "year": 1989 }]Expected output:
[ 'Neuromancer', 'Hyperion' ]3Show solution
The JSON file’s content is the default export, here an array, so filter and map work on it as on any array.
import books from "./books.json" with { type: "json" };
const recent = books.filter((book) => book.year > 1980);console.log(recent.map((book) => book.title));console.log(books.length);[ 'Neuromancer', 'Hyperion' ]3Add a third plugin, plugins/reverse.mjs, whose default export returns the text reversed. Then write run_allowed.mjs with a runPlugin(pluginName, text) function that first checks the name against the list ["shout", "whisper", "reverse"]. If the name is not on the list, it must return a message and must not call import(). Call it with "reverse", "../config" and "reverse" again, using the text "Hello".
Expected output:
reverse plugin loadedolleH"../config" is not an allowed pluginolleHShow solution
Checking the name before building the path keeps user input from choosing which file is loaded. The plugin prints its load message once, because the second import() of it is cached.
console.log("reverse plugin loaded");
export default function run(text) { return [...text].reverse().join("");}const allowed = ["shout", "whisper", "reverse"];
async function runPlugin(pluginName, text) { if (!allowed.includes(pluginName)) { return `"${pluginName}" is not an allowed plugin`; } const plugin = await import(`./plugins/${pluginName}.mjs`); return plugin.default(text);}
for (const name of ["reverse", "../config", "reverse"]) { console.log(await runPlugin(name, "Hello"));}reverse plugin loadedolleH"../config" is not an allowed pluginolleHSummary
- Import JSON with
import data from "./file.json" with { type: "json" }. The attribute is required. import()loads a module while the program runs and returns a promise. A missing file rejects withERR_MODULE_NOT_FOUND.- Top-level
awaitworks in ES modules, and importers wait for it. import.meta.url,import.meta.filenameandimport.meta.dirnamelocate the current file, so you can find files next to it.
Next: reading the older module system you will meet in existing code.