JavaScript Modules - Basics

Chapter 7 36 mins

Learning outcomes:

  1. Module scripts and type="module"
  2. Implicit strict mode
  3. Basic import and export usage
  4. Default imports and exports
  5. Combining named exports using export { ... }
  6. Name aliases using as
  7. Import everything using *

Module scripts

The very first step that we need to do in order to be able to use modules on client-side JavaScript is to understand what are module scripts.

Basically, there can only be two scenarios when a module is referred:

  • It's referred to directly via a <script> tag.
  • It's referred to by a JavaScript file.

Now as per the standard, we can't refer to a module, or simply use just about any module feature, when we are in a JavaScript file that's not loaded as a module itself.

On the same lines, we can't refer to a module via a <script> tag that does not explicitly label this distinction.

But how to do this?

Well, to parse a script as a module, we need to use the type="module" attribute on the <script> tag.

This instructs the browser to execute the script as a module. Otherwise, it executes the script in normal mode. Once a <script> executes the underlying program as a module, we can subsequently refer to other modules in that program.

Remember: module features can be used only inside modules.

So, to summarize it, if we wish to use native modules in client-side code, we have to use the type="module" attribute in the <script> tag that defines the entry point of the code base.

As with normal <script> usage, <script type="module"> can either refer to:

  • an external JavaScript file, or
  • contain the code in the HTML itself (inside the <script> tags).

Two examples are showcased below.

First, a module <script> with code embedded directly inside it:

<script type="module">
   // This is a module.
</script>

And now with a module <script> referring to an external JavaScript file:

<script type="module" src="main.js"></script>

Here's the main.js file:

main.js
// This is a module

This is just how <script> works normally — that is, code can either go directly within the tag or in an external file linked via the src attribute.

Make sure that you don't forget the type="module" attribute in these examples — failing to do so will lead to the scripts being treated as normal JavaScript and, likewise, preventing us from legally using any module features in them.

A crucial observation to make at this point is that modules files are NOT treated as JavaScript by virtue of their .js extensions (just as is the case with regular files); instead, this is determined by their Content-Type HTTP header.

That is, the .js extension has no effect whatsoever on the underlying file being treated as JavaScript — only the Content-Type HTTP response header for the file's request is used for this purpose — it should be equal to application/javascript.

Besides this, there is another thing to take note of, that's common in the realm of module files — the .mjs file extension.

The .mjs extension for module files

Did you ever hear or encounter a .mjs file and wondered what it is?

Well, the .mjs file extension is simply a nice convention to use to indicate that a particular JavaScript file is a module file (in particular, an ES module file). The 'm' stands for 'module.'

In environments such as Node.js, the .mjs extension is more than just a convention; it's required in order for the underlying file to be treated as an ES module file (and thus distinguish it from files having CommonJS modules).

In Node.js, the .mjs extension is only required if the project's package.json doesn't have the type property set to 'module'.

The question is: Do we need to follow this convention to name our module files?

Well, it depends on one's preference, at least for code meant for the client (as opposed to the server).

Some people feel that it makes project structure much clearer by explicitly labelling files that are meant to be used as modules. Others, however, feel that it unnecessarily adds a new level of complexity to the project, that is, to maintain the convention of naming module files as .mjs.

If you do choose .mjs to signify module files, don't forget to appropriately configure the server to send the desired Content-Type header for .mjs files instead of treating them as plain text files.

Implicit strict mode

The second important thing to note, following the application of type="module" to a <script> tag in order to execute it as a module, is that of implicit strict mode in module land.

Based on the standard, module code is executed in strict mode by default (and there's no way to opt out of it). This strict mode is implicit, i.e. we don't have to do anything to get it into action.

Without knowledge of this fact, one might get surprised by certain outcomes in module code that are merely consequences of JavaScript's strict mode.

An example is illustrated below:

<script type="module" src="main.js"></script>
main.js
// Module
x = 10;

Here, we're simply trying to assign a value to an undeclared variable x, which is a perfectly valid operation in non-strict mode.

Uncaught ReferenceError: x is not defined at main.js:1:3

However, by virtue of the fact that the code above is implicitly in strict code, as it's running as a module, we get the assignment operation flagged as illegal.

Simple!

One genuine question that might arise at this stage is: What's the point of making module code to be in strict mode, by default?

Why modules are in strict mode, by default?

After all, we could've explicitly made a given module to transition to strict mode as well, using the 'use strict' directive.

So then why is it applied by default?

Well, wherever we say we "could've" done something (as we said above), we really never. Programmers are lazy people (in a good sense) and since they're mostly humans (obviously AI is coding as well), there's always the possibility of forgetting about something.

If strict mode was made optional in modules, we'd have some code files in strict mode and some not, ultimately leading to inconsistent code bases.

Another nice question is: Why is strict mode so desirable in modules?

Well, modules denote a relatively new feature in JavaScript and strict mode is fruitful for correcting certain weird and loose behaviors of JavaScript from the past. You see, these two things go really well together — modules make JavaScript modern by introducing a cool feature in it, while strict mode ensures that most old weirdness is ruled out of the language.

In addition to this, there might be another practical reason for this choice.

Strict mode is desirable for all JavaScript code, not just modules. Obviously, we couldn't have gotten strict mode, by default, in normal JavaScript as that would've been a backward-incompatible change, breaking older code.

But because strict mode was more than just desirable, and modules denoted a new kind of script, it was decided to make them strict by default. Modules were a new feature and, likewise, it was possible to define specialized semantics for them.

Basic import and export

Now that we have the preliminary information of modules with us, we can finally start exploring how to work inside a module.

And the very first step in this regard is to comprehend the very basics of the import and export keywords.

Let's start with the import keyword:

The import keyword is used to import identifiers from another module into a given module.

import defines the dependencies of modules. Dependencies are merely functionalities that a module depends upon.

For example, if we have a module to compute the sum of the first n positive integers in a proprietary format, we might depend on another module to compute the sum of two given integers in this format.

There are a ton of variations of import, which we'll explore in detail later on below; for now, let's just stick to the variant where we import specific identifiers from another module by referring to their names.

The general syntax is shown below:

import { name1, name2, ..., nameN } from path;

We start off with the import keyword, followed by a pair of curly braces ({}) defining all the identifiers to be imported, followed by the from keyword, and ending with the path of the module to import.

The path is resolved akin to how it's done in HTML, in general, or in JavaScript (for e.g. with the fetch() function).

For example, supposing we are in the example.com/scripts/module.js module and encounter a path in import, here's how it would be resolved in different cases:

  • 'module2.js' would point to example.com/scripts/module2.js.
  • 'utils/logger.js' would point to example.com/scripts/utils/logger.js.
  • '../legacy.js' would point to example.com/legacy.js.
  • '/static/new/main.js' would point to example.com/static/new/main.js.

It's important to note above that the pair of curly braces ({}) doesn't represent object destructuring, per se — it only is a syntactic marker for importing given identifiers from a module.

Now that we have the basics of import dealt with, let's talk about how to export stuff. After all, we can only import an identifier from another module if that module exports it in the first place.

In this regard, we use the export keyword:

The export keyword is used to label the exported identifiers of a module.

Just like we have multiple variants of import, we have them for export as well, but still much less than that of import.

The variant of export we'll cover here is named exports.

The export statement for a named export looks something like the following:

export declarationStatement;

We start off with the export keyword, followed by an identifier declaration statement, such as var x = 10 or maybe class Animal {}.

It's syntactically required to have a statement following export in a named export — we can NOT have an expression as shown below:

var x = 10;
export x;
Uncaught SyntaxError: Unexpected token 'export' (at <file>:2:1)

Moving on, a module could export multiple identifiers, and likewise have multiple export statements.

An example is shown below where we export two different identifiers from the math.js module: PI holding the value of the constant PI and x holding the value of the variable x:

math.js
export const PI = Math.PI;

export var x = 10;

Simple.

Now with the most basic usage of import and export clear, let's now integrate them together into a quick and easy example.

In the following HTML, we link to an external JavaScript file and instruct it to be executed as a module:

<script type="module" src="main.js"></script>

Here's the definition of main.js:

main.js
import { greet } from './greeting.js';

greet();

It calls on another module greeting.js and imports its function greet() before finally invoking the function.

Here's the definition of greeting.js:

main.js
export function greet() {
   console.log('Hello World!');
}

When we run the HTML file in the browser, we get the desired greeting shown to us.

Hello World!

Voila!

The hoisting of import

Just like var statements in JavaScript are hoisted, so are import statements. That is, an imported module is available before the statement where it's imported.

This can be seen below:

main.js
greet(); // greet is available here

import { greet } from './greeting.js';

Remember that hoisting import means that before any code is executed, all import statements are processed first.

Default import and export

Often times, instead of exporting an identifier from a module based on its name, where we can obviously export multiple identifiers, we might want to have a single exported value.

This is called a default export.

The term 'default' arises from the fact that when we import such a module and don't name any identifier to be imported from it, we get back the default value exported by the module.

To specify a module's default export, we use the default keyword, as shown below:

export default value;

As with a named export, we start off with the export keyword, this time followed by the default keyword, followed by a value to act as the default export.

Since a default export doesn't have a name, i.e. it's just concerned with the value, we don't need to have a statement following the default keyword in a default export. In fact, it's invalid to do so.

An example is illustrated below:

// Exporting a statement is invalid!
export default var x = 10;
Uncaught SyntaxError: Unexpected token 'var' (at <file>:2:16)

var x, as we know, denotes a statement in JavaScript; henceforth, it represents an invalid operation in case it comes after export default, where only expressions are expected.

A default export can be any valid expression in JavaScript:

export default 10;
export default 'Hello World!';
export default {
   name: 'JavaScript',
   year: 1995
};

Note that a module can have at most one default export. Failing to abide by this rule would result in a syntax error.

The following code is a testimony to this:

export default 10;

// Another default export.
export default 'Hello World!'
Uncaught SyntaxError: Identifier '.default' has already been declared (at <file>:4:8)

After having made one default export in the first line, we make another one in line 4 and that's exactly what causes the error.

The error message might seem weird, as it's referring to an identifier called default, but as we shall see soon, it makes perfect sense and goes with the norm of throwing whenever an exported name is exported again.

Moving on, in order to import another module's default export, we ought to use a default import.

A default import isn't specified inside the pair of curly braces ({}) in an import statement; instead, it's defined outside the braces, either before them or after them, and then given a name.

This different syntax helps us distinguish default imports from named imports, and even makes it possible to have both of them at a time.

Here's the syntax of a minimal default import:

import defaultName from path;

Following the import keyword, we have a name referring to the default export of the imported module.

As stated earlier, we could even have named imports besides a default import:

import defaultName, { ... } from path;

So, for instance, supposing that a module greet.js has a default export 10, as follows:

greet.js
export default function greet() {
   console.log('Hello World!');
}

we can do the following to import it and thereafter use it:

import greet from './greet.js';

greet();
Hello World!

Note that since there's no name for a default export, we can call it anything we like at the time of its import. For example, in the following code, we import the greet.js module as fn:

import fn from './greet.js';

fn();

At this stage, it's worthwhile noting that the default export and named exports of a module are two disparate ideas.

There are two common misconceptions:

  1. The default export of a module is the entire set of named exports while each named export is an individual item of that set.
  2. The default export being an object literal is equal to having named exports for those properties.

Both of these assertions are far away from truth.

As stated before, by definition, the default and named exports of a module are two completely different things. One doesn't imply the other.

When to use default exports?

Default exports are typically used:

  • when a module exports just one thing, or
  • when it has a main functionality to be exported besides some less important functionalities (each of which becomes a named export).

For instance, if we have a module for an autocompleter utility, the default export might be an Autocompleter class used to implement autocompletion functionality on a web page, which the named exports might point to functions provided by the module to be used to configure an Autocompleter instance.

Similarly, if we have a module meant to define the global configurations for a code base, it might have a default export as an object containing all the configurations as properties of that object.

Some people prefer to avoid default exports completely owing to the fact that they don't have a name and so, while importing a default export, there's always the chance of misnaming the import.

For instance, in the following code,

logger.js
export default function logWarning(err) {
   console.warn(err);
}

the default export is a function called logWarning, however when we import it, we might call it logWarning (notice the typo) and then later on, refer to it as logWarning just to end up with red signals, flagging the access of a non-existent identifier:

import logWarning from './logger.js';

logWarning('This is an error');
Uncaught ReferenceError: logWarning is not defined at <file>:3:1

The issue is not the error but the fact that we can't immediately figure out it's happening because of a clerical mistake while typing the name of the default import.

Now, let's try doing the same thing without using default imports/exports:

logger.js
export function logWarning(err) {
   console.warn(err);
}

Since the function logWarning() is being exported as a named export, we can import it via a named import, as follows:

import { logWarning } from './logger.js';

logWarning('This is an error');

As before, the typo is stil there in the code, but let's see the error message this time:

Uncaught SyntaxError: The requested module './greeting.js' does not provide an export named 'logWarning' (at <file>:1:10)

It clearly tells us that we're trying to import logWarning from greeting.js, yet it doesn't have such an export. This gets us to re-evaluate the location where we import greeting.js and spot the typing mistake in seconds.

Much better!

Now whether you also follow feat and abstain from using default imports/exports at all is your own call. (Nice rhyme!)

Default imports/exports aren't that bad; if we can ensure consistency in naming default imports the same as the corresponding default exports and look out for clerical errors in them, then we could surely use them.

Combined named exports

The export keyword as we saw above can be used to export identifiers as they are declared in a module.

For example, in the following code, we export a class Logger, a function log(), and a constant LOG_LEVEL from a logger.js module as they are defined:

logger.js
export class Logger {};

export function log(value) {
   console.log(value);
}

export const LOG_LEVEL = 1;

Sometimes though, we might want to group all of our named exports in one place.

After all, by having multiple exports sprinkled throughout a module, we can't right away tell about all of the named exports of that module.

Fortunately, the export keyword supports combined named exports as well.

Here's the syntax to do so:

export {
   name1,
   name2,
   ...,
   nameN,
}

The export keyword is followed by a pair of curly braces ({}), containing all the names to export.

It's important to note here that export { ... } is NOT exporting an object literal — it's rather a specialized syntax to combine named exports in one place.

Let's now see an example.

In the following code, we combine the exports from the logger.js module showcased above:

logger.js
class Logger {};

function log(value) {
   console.log(value);
}

const LOG_LEVEL = 1;

export {
Logger,
log
LOG_LEVEL
};

This is equivalent to the previous code snippet, shown above, just that the exports are all under one roof.

In a combined named export, the order of names obviously doesn't matter.

Name aliases

When importing given identifiers from a module, sometimes it's convenient to import them with different names that what they're actually called.

For example, we might want to call the exported greet() function of a module greeting.js as legacyGreet(), possibly because greet() already exists in the importing module, or maybe call it simply as g() to act as a quick and concise way to refer to the function.

This is referred to as name aliasing an import.

As you can guess, name aliases only work with named imports; we already name default imports ourselves so there's absolutely no point of having the functionality to alias them.

To alias a module's named import, we use the as keyword, as shown below:

import { name as aliasName, ... }

For example, going with the name aliasing example we discussed above, we'd have:

import { greet as legacyGreet } from './greeting.js';

// Module has its own greet() function.
function greet() { ... }

The import statement here reads pretty well: "import greet as legacyGreet from the greeting.js module."

Besides aliasing imports, we can also alias exports using the combined export syntax which we saw above.

The syntax is actually the same — the as keyword along with the alias name follows the actual name of the identifier:

export {
   name as aliasName,
   ...,
}

Again, going with the example above, we can alias the greet() function right inside the greeting.js module itself, supposing that it has now become a legacy function.

A demonstration follows:

greeting.js
function greet() {
   console.log('Hello World!');
}

export {
   greet as legacyGreet
};

The lines 5 - 7 are the crux here — we have a combined set of named exports, in this case with just one export, with the identifier greet being aliased as legacyGreet.

Now, if we import this module in another module, we'll have to refer to legacyGreet:

import { legacyGreet } from './greeting.js';

legacyGreet();
Hello World!

Import using *

Let's say we have a module that has two named exports, log and logError, as shown below, in addition to a default export:

logger.js
export function log(value) {
   console.log(value);
}

export function logError(err) {
   console.error(err)
}

class Logger {}

export default Logger;

We want to import this module such that we get an identifier to work with and it includes the default export as well as the named exports of the module.

How can we do this?

Well, by using the import * variant. Let's see what it is.

The import keyword can be followed by an asterisk (*) to load everything from a given module and dump it into an object whose name is given after the asterisk.

We can effectively refer to this as importing everything from a module under a namespace, that is, a name encapsulating related functionality.

Here's the syntax to use import *:

import * as objectName from path;

After the asterisk (*) comes the as keyword, followed by the name of the object that'll hold the default and named exports of the concerned module.

The named exports become properties of the object, with property names akin to the names of the exports, while the default export goes into a property named default.

Quite basic, isn't it?

Can't import everything without a namespace!

Some other languages that support modules allow all of the exports of one module to be imported into another module in one single statement.

As an example, here's a snippet containing Python code:

from math import *

print(factorial(10))
print(comb(5, 2))
print(log(5.5))

It imports everything from the math module into the current environment (without any encapsulating namespace).

That is, the math module in Python defines some functions such as factorial(), comb(), log(), and many others, which are all made available, thanks to the from math import * statement.

In JavaScript, there's NO way to import everything from a module without any namespace (as we did in the Python code above).

The following code might seem valid JavaScript, but it ain't:

import * from './greeting.js';
Uncaught SyntaxError: Unexpected identifier 'from'

Stating it again, it's NOT possible to import everything from a module without a qualifier (i.e. a namespace).

But why isn't this allowed?

  1. First of all, this is generally not considered good practice. It obscures the fact as to which identifier comes from which module, and that makes code less readable and difficult to manage.
  2. Secondly, if this was supported, how would default exports be entertained. Remember that they are anonymous and so we'd have to name them any way at the instant of import.

It's much better to import everything from a module under a given namespace, and that we've explored how to do in the discussion above.

Let's now take a look at a concrete example to better understand such imports. We'll use the example shown above:

Here's the logger.js module, again:

logger.js
export function log(value) {
   console.log(value);
}

export function logError(err) {
   console.error(err)
}

class Logger {}

export default Logger;

And here's the module where we import everything from logger.js under the namespace LoggerModule:

import * as LoggerModule from './logger.js';

Let's try working with the functionality imported into LoggerModule:

import * as LoggerModule from './logger.js';

LoggerModule.log('Hello');
LoggerModule.logError('This is an error');

var loggerObj = new LoggerModule.default();
console.log(loggerObj);
Hello
This is an error
Logger {}

LoggerModule.log() and LoggerModule.logError() are fairly straighforward to understand.

LoggerModule.default represents the default import of the loaded module, which is just the default export of logger.js, and that is the class Logger. Likewise, we call LoggerModule.default() with the new keyword to instantiate a Logger instance.

Note that the LoggerModule object doesn't have a prototype, as confirmed by the following code:

import * as LoggerModule from './logger.js';

console.log(Object.getPrototypeOf(LoggerModule));
null

Moreover, it's invalid to add any new property to such an object or to modify an existing property:

import * as LoggerModule from './logger.js';

LoggerModule.x = 10;
Uncaught TypeError: Cannot add property x, object is not extensible

Besides this, it's possible to have named and default imports along with a * import.

A couple of examples are illustrated below:

import Logger, * as LoggerModule from './logger.js';
import { log, logError }, * as LoggerModule from './logger.js';
import Logger, { log, logError }, * as LoggerModule from './logger.js';

While it's possible, it's not very common to see such imports in code snippets out there.