Skip to content
JavaScript Basics: Split Reusable Code Into ES Modules

JavaScript Basics: Split Reusable Code Into ES Modules

October 14, 2026

When two scripts need the same helper function, copying it creates two places to maintain. An ES module lets one file export useful code and another import it. We’ll put a greeting function in a reusable module, call it from an application, and test it independently.

Prepare a scratch directory

You need Linux, Bash, the mktemp, rm, and rmdir utilities, a text editor, and Node.js on your PATH. These examples were tested with Node.js v24.19.0 on Linux. No npm packages, package manifest, network access, or custom environment variables are required.

Run in Bash:

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

Keep this terminal open and save the following three files as UTF-8 in the new directory. Use the exact filenames, including .mjs. That extension explicitly tells Node to use ES modules without needing a package.json. Node documents its module markers here.

Export a reusable function

Save as greeting-tools.mjs:

const greeting = "Hello";

export const defaultName = "Guest";

export default function greet(name = defaultName) {
  if (typeof name !== "string") {
    throw new TypeError("Name must be a string");
  }

  const cleaned = name.trim();
  if (cleaned.length === 0) {
    throw new RangeError("Name must not be blank");
  }

  return `${greeting}, ${cleaned}!`;
}

This file exports two things:

  • defaultName is a named export. Importers refer to that exported name, optionally giving it a local alias.
  • greet is the default export. A module can have one default export, and an importer chooses its local name.

The greeting constant stays inside the module because it is not exported. That keeps the public interface small. The function returns a string without printing anything, so callers can decide how to use the result. See MDN’s export reference.

The function accepts a nonblank string. Omitting the argument, or passing undefined, uses defaultName. Passing null does not trigger the default and fails the string check.

Import it into an application

Save as show-greetings.mjs:

import makeGreeting, { defaultName as guestName } from "./greeting-tools.mjs";

console.log(makeGreeting("  Mira  "));
console.log(makeGreeting());
console.log(`Default name: ${guestName}`);

Run:

node show-greetings.mjs

Expected output:

Hello, Mira!
Hello, Guest!
Default name: Guest

The name before the braces, makeGreeting, receives the default export. Inside the braces, defaultName as guestName imports a named export and renames it locally. These are two distinct import forms; the braces are not object destructuring. MDN’s import reference shows both forms.

The ./ means the helper is beside the importing file. Include the .mjs extension: Node’s ES module loader does not automatically try missing extensions on relative imports. The path is relative to show-greetings.mjs, not to the terminal’s working directory. See Node’s import specifier rules.

You can check that last point by launching the same application from the directory where you began:

(cd -- "$start_dir" && node "$work_dir/show-greetings.mjs")

Expected output is unchanged:

Hello, Mira!
Hello, Guest!
Default name: Guest

The parentheses run that command in a subshell, so your main terminal remains in the scratch directory.

Test the helper separately

Save as check-greeting-tools.mjs:

import assert from "node:assert/strict";
import greet, { defaultName } from "./greeting-tools.mjs";

assert.equal(defaultName, "Guest");
assert.equal(greet("Mira"), "Hello, Mira!");
assert.equal(greet("  Mira  "), "Hello, Mira!");
assert.equal(greet("東京"), "Hello, 東京!");
assert.equal(greet(), "Hello, Guest!");
assert.equal(greet(undefined), "Hello, Guest!");
console.log("Normal, padded, Unicode, and default names: passed");

for (const input of ["", " \t\n "]) {
  assert.throws(() => greet(input), {
    name: "RangeError",
    message: "Name must not be blank",
  });
}
console.log("Blank names: rejected with RangeError");

for (const input of [null, 42, false, {}]) {
  assert.throws(() => greet(input), {
    name: "TypeError",
    message: "Name must be a string",
  });
}
console.log("Non-string names: rejected with TypeError");

Run:

node check-greeting-tools.mjs

Expected output:

Normal, padded, Unicode, and default names: passed
Blank names: rejected with RangeError
Non-string names: rejected with TypeError

Both the application and the test use the same implementation. The test does not import the application, so it does not print the application’s greetings. assert.throws calls the supplied function and checks the expected error; an absent or mismatched error fails the test. See Node’s assertion documentation.

Recognize common import errors

If a local import reports ERR_MODULE_NOT_FOUND, check the file’s spelling and capitalization, the leading ./, and the extension. A bare name such as greeting-tools.mjs is treated as a package specifier rather than a neighboring file.

If an error says a module “does not provide an export named” something, compare the import with the helper’s actual exports. Our helper’s default function is called greet internally, but that does not also create a named export called greet. Importing it as a default, as the test does, works; asking for a named export called greet fails. Only defaultName is a named export in this example.

Top-level module code runs when the module is evaluated. Keep demonstrations and printing in the application, rather than adding them to a helper that every caller imports. These files use ES module import and export; CommonJS examples using require or module.exports need their own setup. MDN’s module guide provides further examples.

Clean up

After running the application and tests, remove only these three files:

rm -- greeting-tools.mjs show-greetings.mjs check-greeting-tools.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Start by extracting one useful function, give it a clear interface, and keep its callers small. A separate test file then lets you check the shared behavior without running the whole application.

Acknowledgements

This post was created with assistance from AI.

Last updated on