Modules
A file is a module. Three consequences follow, and they are the whole model. Its top level is private: everything declared there is invisible outside unless you export it, so there is no global leakage to defend against. The boundary is declared: export says what leaves, import says what enters, and both are statements the tooling can read without running anything, which is what makes a module's shape known before evaluation. And it is evaluated once: however many modules import it, the body runs a single time and everyone shares the result, so a module is also the language's least ceremonious singleton.
The one thing that surprises people, and the reason this entry has depths: an import is not a copy of a value. It is a live, read-only view of a binding in another module.
Create programs/modules.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import. This entry is also the first whose test file has neighbours: the modules it measures live in a programs/modules/ directory beside it, and each step names the file it adds there.
named exports and named imports
The first neighbour, in programs/modules/tally.ts:
export let tally = 7;
export function incTally(): void {
tally++;
}
export const LIGHT_SPEED = 299792458;
The test file imports from it, above the tests:
import {
tally,
incTally,
LIGHT_SPEED,
LIGHT_SPEED as c,
} from "./modules/tally.ts";
Deno.test("named exports and named imports", () => {
assertStrictEquals(LIGHT_SPEED, 299792458);
assertStrictEquals(c, LIGHT_SPEED);
assertStrictEquals(tally, 7);
});
Check programs/modules.test.ts
running 1 test from ./programs/modules.test.ts
named exports and named imports ... ok (168µs)
ok | 1 passed | 0 failed (1ms)
Put export in front of a declaration and it becomes a named export; anything without it is private to the file, which is the default worth relying on. An import can rename with as, and note which side is doing the renaming: the importer chooses the local name c, and the exporting module never learns about it. There is also an export clause that lists exports separately at the bottom and can rename on the way out, which a later step uses; the inline form is better for most files, because the export is visible where the thing is defined rather than in a list that drifts.
namespace imports
One more import of the same file:
import * as arithmetic from "./modules/tally.ts";
Deno.test("namespace imports", () => {
assertStrictEquals(arithmetic.LIGHT_SPEED, 299792458);
assertStrictEquals(arithmetic.tally, tally);
});
namespace imports ... ok (33µs)
import * as arithmetic gathers every named export into one object. Useful when a module has many exports that read better with a prefix, and the object itself has properties worth knowing about, two steps down.
default exports
A second neighbour, programs/modules/greet.ts, and its import:
const GREETING = "Hello!";
export default function greet(): string {
return GREETING;
}
import greet from "./modules/greet.ts";
Deno.test("default exports", () => {
assertStrictEquals(greet(), "Hello!");
});
default exports ... ok (34µs)
At most one per module, and the braces are the syntactic tell: named imports reach into a module, while a default import is the module. GREETING stays private, which is the top-level privacy from the answer doing its quiet work. There are two spellings, export default function f() {} labelling a declaration and export default someValue exporting a value directly, and the reason for the second is worth knowing: export default const a = 1, b = 2 cannot exist, because const may define several values and a default export has to be exactly one, so const can never be labelled and the value form fills the gap.
an import is a live view
incTally lives in the other module and rewrites the other module's binding. Predict what this module's tally shows afterwards:
Deno.test("an import is a live view", () => {
assertStrictEquals(tally, 7);
incTally();
assertStrictEquals(tally, 7);
});
Check programs/modules.test.ts
running 4 tests from ./programs/modules.test.ts
...
an import is a live view ... FAILED (8ms)
ERRORS
an import is a live view => ./programs/modules.test.ts:41:6
error: AssertionError: Values are not strictly equal.
[Diff] Actual / Expected
- 8
+ 7
FAILURES
an import is a live view => ./programs/modules.test.ts:41:6
FAILED | 3 passed | 1 failed (9ms)
error: Test failed
The import saw the change. Nothing reassigned tally here; a function in the other module did, and what was imported is the binding rather than the value it held at import time. This is the single most important thing to understand about modules, and it is what makes splitting a file safe: a variable that two functions shared before the split can become an export, and the functions keep working. Correct the prediction to 8, then try the write from this side:
Deno.test("an import is a live view", () => {
assertStrictEquals(tally, 7);
incTally();
assertStrictEquals(tally, 8);
tally = 99;
});
Check programs/modules.test.ts
TS2632 [ERROR]: Cannot assign to 'tally' because it is an import.
tally = 99;
~~~~~
at file:///programs/modules.test.ts:48:3
error: Type checking failed.
The view is read-only from this side, and the linter agrees through no-import-assign, so pinning the runtime proof takes two signatures:
Deno.test("an import is a live view", () => {
assertStrictEquals(tally, 7);
incTally();
assertStrictEquals(tally, 8);
assertThrows(
() => {
// @ts-expect-error: Cannot assign to 'tally' because it is an import.
// deno-lint-ignore no-import-assign
tally = 99;
},
TypeError,
"Assignment to constant variable.",
);
});
an import is a live view ... ok (294µs)
The runtime message is worth a second look: Assignment to constant variable., the very same message the assignment page met for reassigning a const. That is not a borrowed string. An import really is a constant binding in this module, and the only code that may change it is the module that declared it.
a namespace object is not a plain object
The keys of arithmetic, its prototype, and its seals, ending on a contradiction: read the descriptor, believe it, and write:
Deno.test("a namespace object is not a plain object", () => {
assertEquals(Object.keys(arithmetic), [
"LIGHT_SPEED",
"incTally",
"tally",
]);
assertStrictEquals(Object.getPrototypeOf(arithmetic), null);
assert(Object.isSealed(arithmetic));
assertFalse(Object.isFrozen(arithmetic));
assertFalse(Object.isExtensible(arithmetic));
const descriptor = Object.getOwnPropertyDescriptor(
arithmetic,
"LIGHT_SPEED",
);
assertStrictEquals(descriptor?.writable, true);
assertStrictEquals(descriptor?.configurable, false);
(arithmetic as unknown as Record<string, unknown>).LIGHT_SPEED = 1;
});
Check programs/modules.test.ts
running 5 tests from ./programs/modules.test.ts
...
a namespace object is not a plain object ... FAILED (420µs)
ERRORS
a namespace object is not a plain object => ./programs/modules.test.ts:59:6
error: TypeError: Cannot assign to property 'LIGHT_SPEED' of [object Module]
(arithmetic as unknown as Record<string, unknown>).LIGHT_SPEED = 1;
^
FAILURES
a namespace object is not a plain object => ./programs/modules.test.ts:59:6
FAILED | 4 passed | 1 failed (2ms)
error: Test failed
writable: true, and the write threw anyway. That looks contradictory until you remember what the previous step established: the descriptor is describing the binding, which really can change, because the exporting module can change it. What it is not describing is your permission. A namespace object is an exotic object whose set operation always fails, which is why the error names [object Module] rather than an ordinary object. So writable: true means "this value may change under you", not "you may change it".
Two more things the step pinned on the way. The keys come back sorted alphabetically, not in declaration order, so LIGHT_SPEED precedes tally even though it was written last. And the prototype is null, the chain of one from the prototypes and inheritance page, so it inherits nothing at all, not even toString. Sealed but not frozen, not extensible, nothing configurable: treat it as a fixed view rather than an object you own, and wrap the write in assertThrows to keep the exhibit:
Deno.test("a namespace object is not a plain object", () => {
assertEquals(Object.keys(arithmetic), [
"LIGHT_SPEED",
"incTally",
"tally",
]);
assertStrictEquals(Object.getPrototypeOf(arithmetic), null);
assert(Object.isSealed(arithmetic));
assertFalse(Object.isFrozen(arithmetic));
assertFalse(Object.isExtensible(arithmetic));
const descriptor = Object.getOwnPropertyDescriptor(
arithmetic,
"LIGHT_SPEED",
);
assertStrictEquals(descriptor?.writable, true);
assertStrictEquals(descriptor?.configurable, false);
assertThrows(
() => {
(arithmetic as unknown as Record<string, unknown>).LIGHT_SPEED = 1;
},
TypeError,
"Cannot assign to property 'LIGHT_SPEED' of [object Module]",
);
});
a namespace object is not a plain object ... ok (208µs)
the default export is a named export called default
Two more neighbours. programs/modules/internal.ts has named exports and a default, and programs/modules/library.ts re-exports it three ways, for this step and the next:
export const INTERNAL_DEF = "hello";
export function internalFunc(): string {
return INTERNAL_DEF;
}
export default 123;
export { INTERNAL_DEF as DEF, internalFunc as func } from "./internal.ts";
export * from "./internal.ts";
export * as ns from "./internal.ts";
import * as library from "./modules/library.ts";
Deno.test("the default export is a named export called default", () => {
assertStrictEquals(library.ns.default, 123);
assertEquals(Object.keys(library.ns), [
"INTERNAL_DEF",
"default",
"internalFunc",
]);
});
the default export is a named export called default ... ok (40µs)
There is no separate mechanism. export default 123 is an export whose name is the string "default", which is why it shows up on a namespace object and why import { default as x } works. default cannot be a variable name, but it can be an export name and a property key, and that is all this needs. Knowing this makes the next step make sense.
three ways to re-export, and one drops the default
Deno.test("three ways to re-export, and one drops the default", () => {
assertEquals(Object.keys(library), [
"DEF",
"INTERNAL_DEF",
"func",
"internalFunc",
"ns",
]);
assertStrictEquals(library.DEF, "hello");
assertStrictEquals(library.INTERNAL_DEF, "hello");
assertFalse("default" in library);
});
three ways to re-export, and one drops the default ... ok (43µs)
A named re-export picks and renames. A wildcard re-export forwards everything except the default, which the last line verifies and which is the asymmetry to remember. A namespace re-export bundles the other module into a single named export, and because that is a namespace object it does include default, as the previous step measured. The wildcard's exception is defensible once you accept the previous step: two modules each with a default cannot both forward it under the same name, so wildcard forwarding leaves it alone.
a module is evaluated once
A module with a side effect, programs/modules/evaluations.ts, and a re-exporting middleman, programs/modules/reexports.ts, so the test reaches the same file by two routes:
export const evaluations: string[] = [];
evaluations.push("evaluated");
export { evaluations as sameArray } from "./evaluations.ts";
import { evaluations } from "./modules/evaluations.ts";
import { sameArray } from "./modules/reexports.ts";
Deno.test("a module is evaluated once", () => {
assertEquals(evaluations, ["evaluated"]);
assertStrictEquals(evaluations, sameArray);
});
a module is evaluated once ... ok (23µs)
The push ran once, and sameArray is not an equal array but the identical object, the distinction from the values and references page. That makes a module a reasonable place for something there should only be one of: a cache, a connection pool, a configuration object. It also makes top-level side effects worth being careful with, because you do not control when they run, only that they run once and before anyone reads an export.
a cycle resolves
Two modules importing each other, programs/modules/cycle-a.ts and programs/modules/cycle-b.ts:
import { b } from "./cycle-b.ts";
export function a(): string {
return "A";
}
export function bothWays(): string {
return b() + a();
}
import { a } from "./cycle-a.ts";
export function b(): string {
return "B";
}
export function viaCycle(): string {
return a() + "!";
}
import { bothWays } from "./modules/cycle-a.ts";
import { viaCycle } from "./modules/cycle-b.ts";
Deno.test("a cycle resolves", () => {
assertStrictEquals(bothWays(), "BA");
assertStrictEquals(viaCycle(), "A!");
});
a cycle resolves ... ok (24µs)
It works, which is unusual enough among module systems to be worth explaining. Setting up modules happens in two phases: instantiation connects every import to its export, all the way down, before any body runs, and evaluation then runs the bodies, children before parents. So when the second module's body runs, the first has been instantiated but not evaluated: its bindings exist and are still empty. That is why viaCycle can mention a before cycle-a.ts has run; the binding is there to be referenced, and only reading its value has to wait. Cycles are fine, and the one rule is that you cannot use an imported value during evaluation of a cycle, only after. Live bindings are what make this possible, which is the other reason an import is a live view matters.
import.meta
Deno.test("import.meta", () => {
assert(import.meta.url.endsWith("/modules.test.ts"));
assert(import.meta.resolve("./modules/tally.ts").endsWith("/tally.ts"));
assertStrictEquals(typeof import.meta.dirname, "string");
assertStrictEquals(typeof import.meta.filename, "string");
assertStrictEquals(Object.getPrototypeOf(import.meta), null);
assertFalse(import.meta.main);
const dataUrl = new URL("modules/config.json", import.meta.url);
assert(dataUrl.href.endsWith("/modules/config.json"));
});
import.meta ... ok (111µs)
import.meta holds metadata about the current module, with a null prototype, which pays off the objects as dictionaries page's list of null-prototype tables. Deno puts more on it than the standard requires: url is the standard one, resolve turns a specifier into the URL it would import, dirname and filename are conveniences that save a conversion, and import.meta.main answers "was this file the entry point", which is false here because the test runner is. The portable move, and the one to reach for first, is the new URL(..., import.meta.url) at the bottom: relative to the module rather than to the working directory, which is the distinction that makes it correct, since a bare "config.json" path resolves against wherever the process happened to be started.
a JSON module arrives typed
The file the URL pointed at, programs/modules/config.json, imported with an attribute:
{
"version": "1.0.0",
"maxCount": 20
}
import config from "./modules/config.json" with { type: "json" };
Misspell a property to see what the checker knows:
Deno.test("a JSON module arrives typed", () => {
assertStrictEquals(config.verison, "1.0.0");
});
Check programs/modules.test.ts
TS2551 [ERROR]: Property 'verison' does not exist on type '{ version: string; maxCount: number; }'. Did you mean 'version'?
assertStrictEquals(config.verison, "1.0.0");
~~~~~~~
at file:///programs/modules.test.ts:133:29
'version' is declared here.
"version": "1.0.0",
~~~~~~~~~~~~~~~~~~
at file:///programs/modules/config.json:2:3
error: Type checking failed.
The secondary note points into the JSON file itself: the properties are typed from the file's actual contents, so a typo in a property name is a compile error rather than an undefined, which makes this a better way to read configuration than parsing it yourself. Fix the spelling:
Deno.test("a JSON module arrives typed", () => {
assertStrictEquals(config.version, "1.0.0");
assertStrictEquals(config.maxCount, 20);
assertStrictEquals(config.version.toUpperCase(), "1.0.0");
});
a JSON module arrives typed ... ok (24µs)
The with clause is an import attribute, and it is not decoration you can omit: the type is asserted rather than guessed, because deciding what is inside a file from its extension is exactly the mistake the web spent years unlearning. The last line is the part worth noticing: version is a string with string methods on it, not any.
module scope is not global scope
One top-level line in the test file, above the tests:
const notGlobal = "module scope";
Deno.test("module scope is not global scope", () => {
assertStrictEquals(notGlobal, "module scope");
assertFalse("notGlobal" in globalThis);
assertStrictEquals(this, undefined);
});
module scope is not global scope ... ok (13µs)
A top-level const in a module is not a property of anything. It is a binding in that module's scope, so globalThis never learns about it, which is the concrete version of the promise made in the answer and the fact the scope and declarations page filed under there is no global scope to fall into. A module's top-level this is undefined rather than the global object, from the value of this's at the top of a module, this is undefined, a deliberate difference from the old script behavior.
One paragraph of history, because it explains the design rather than just dating it. Before modules there were scripts, which ran in global scope and communicated through global variables, and the workaround was to wrap a file in an immediately invoked function and assign one global on the way out. Dependencies were not stated in the file, so the page had to load them in the right order by hand. The two pre-standard module systems that followed, CommonJS on servers and AMD in browsers, existed to fix that, and modules took the compact syntax from one and the asynchronous loading from the other. Everything in this entry that feels strict is strict because of what the alternative was.
In practice
- Prefer named exports so definitions are searchable, importers can rename them, and typos become compile errors.
- Reserve a default export for a module that genuinely represents one thing, and do not mix export styles in one file.
- Prefer an exported initialization function to invisible top-level side effects when a module must do work before use.
- Do not reorganize harmless cycles preemptively; a troublesome cycle may mean two modules represent one concept.
- Use
new URL(..., import.meta.url)when a module needs a file beside itself.