bastianplsfix

Module specifiers

The string after from is a module specifier, and resolving it means turning it into a URL. Everything that is ever confusing about imports is confusing because of how that one step works.

There are three kinds. Absolute specifiers are already URLs, so resolution is finished before it starts. Relative specifiers, beginning with ./, ../, or /, are resolved against the importing module's own URL, which is plain URL arithmetic and involves no searching. Bare specifiers, which start with neither a dot nor a slash, are names that mean nothing on their own and need something else to say what URL they stand for. Only the third kind is hard, and every runtime answers it differently; Deno's answer is one file, and it is short.

Create programs/module-specifiers.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assert, assertStrictEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import. The modules page's neighbours stay where they are, because one of them is about to be resolved from here.

relative specifiers resolve against the module

One tiny neighbour, programs/sibling.ts:

export const sibling = "here";
import { sibling } from "./sibling.ts";

Deno.test("relative specifiers resolve against the module", () => {
const resolved = import.meta.resolve("./sibling.ts");

assert(resolved.startsWith("file:///"));
assert(resolved.endsWith("/programs/sibling.ts"));
assertStrictEquals(sibling, "here");

const neighbour = import.meta.resolve("./modules/tally.ts");
assert(neighbour.endsWith("/programs/modules/tally.ts"));
});
Check programs/module-specifiers.test.ts
running 1 test from ./programs/module-specifiers.test.ts
relative specifiers resolve against the module ... ok (193µs)

ok | 1 passed | 0 failed (1ms)

import.meta.resolve does exactly the resolution step and then stops, which makes it the right tool for seeing what a specifier means without loading anything. Every example in this entry uses it, and none of them touch the network. The result is a full file: URL, and which directory the process was started in never enters into it: the second resolve reaches the modules page's tally.ts from this module's own position. A module always knows where it is; a process only knows where it was launched.

the extension is part of the name

Drop the extension, in a scratch file programs/noext.ts:

import { sibling } from "./sibling";

console.log(sibling);
Check programs/noext.ts
TS2307 [ERROR]: Cannot find module 'file:///programs/sibling'. Maybe add a '.ts' extension or run with --sloppy-imports
at file:///programs/noext.ts:1:25

error: Type checking failed.

The hint names both ways out, and the first one is the one to take. Extensions are required because a specifier is a URL, and a URL names a file rather than describing a search. --sloppy-imports exists for porting a Node codebase and is not a style choice. Delete the scratch file.

absolute specifiers, and the two schemes Deno adds

Deno.test("absolute specifiers pass through", () => {
assertStrictEquals(
import.meta.resolve("https://example.com/a.js"),
"https://example.com/a.js",
);

assertStrictEquals(import.meta.resolve("jsr:@std/path"), "jsr:@std/path");
assertStrictEquals(import.meta.resolve("npm:chalk"), "npm:chalk");
});
absolute specifiers pass through ... ok (52µs)

https: is an ordinary URL and needs nothing explained. jsr: and npm: are Deno's, and they are the interesting part: a registry name written directly in the specifier, so a dependency can be named without a manifest, an installer, or a directory of downloaded code. They pass through resolution unchanged here because there is nothing to look up. The name is the address.

a bare specifier travels through three steps

The project's own deno.json is a JSON module like any other, from the modules page's a JSON module arrives typed, so the test can read the first step of the chain directly:

import config from "../deno.json" with { type: "json" };
Deno.test("a bare specifier travels through three steps", () => {
const mapped = config.imports["@std/assert"];
assert(mapped.startsWith("jsr:@std/assert@"));

const resolved = import.meta.resolve("@std/assert");
const match = resolved.match(
/^https:\/\/jsr\.io\/@std\/assert\/(\d+\.\d+\.\d+)\/mod\.ts$/,
);
assert(match !== null);
});
a bare specifier travels through three steps ... ok (48µs)

The middle step lives in deno.lock, which pins the range to one version. It is not importable as a module, so look at the file itself:

  "specifiers": {
"jsr:@std/assert@1": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14"
},

Three files, three jobs, no algorithm. deno.json decides what a name means, so @std/assert becomes jsr:@std/assert@^1.0.0. The lockfile decides which version, storing the range in normalized form, which is why its key reads jsr:@std/assert@1 rather than the caret spelling, and pinning it to 1.0.19. And resolution ends at the URL the code actually comes from, https://jsr.io/@std/assert/1.0.19/mod.ts, which is what the regular expression in the test pulled the version back out of. Compare what the same question costs elsewhere: walking up the directory tree looking for node_modules, then reading a manifest, then interpreting a conditional exports map.

One detail if you resolve bare names yourself. What import.meta.resolve hands back depends on how far resolution has already got: in a module that does not import @std/assert, it returns the import-map value, and in one that does, like this test file, it returns the final URL, because by then the dependency is in the graph. Both are correct answers to different questions.

deno.json is an import map

The mapping above is not a Deno invention. It is the import maps format, and imports in deno.json is that field. Run deno add jsr:@std/encoding and this is the entire output:

Add jsr:@std/encoding@1.0.11

And this is the whole result, one new line in deno.json plus the lock entries:

{
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.0",
"@std/encoding": "jsr:@std/encoding@^1.0.11"
}
}

There is no other state: no installed directory, no manifest fields to learn, no lock format to hand-edit. Adding a dependency edits one object. The practical consequence is the one worth internalising: a version appears in exactly one place. In a codebase with URL specifiers scattered through it, an upgrade is a search and replace across files, and the failure mode is two versions of one library loaded at once. With a map, the specifier in your code is a name and the version lives beside it.

A URL specifier is still the most honest dependency declaration there is: it says precisely where the code comes from, and it needs no tool to interpret it. It has two prices. The first is that the code you run is whatever that host serves today, which is why the lockfile is not optional bookkeeping: it records an integrity hash per resolved dependency, so a changed response is a failed run rather than a silent substitution. The second is duplication. Both prices are paid by putting the URL in the import map once and importing a name.

a bare specifier with no mapping is an error

Resolve a name the map has never heard of:

Deno.test("a bare specifier with no mapping is an error", () => {
const nowhere = import.meta.resolve("not-in-the-map");

assert(nowhere.startsWith("file:"));
});
Check programs/module-specifiers.test.ts
running 4 tests from ./programs/module-specifiers.test.ts
...
a bare specifier with no mapping is an error ... FAILED (262µs)

ERRORS

a bare specifier with no mapping is an error => ./programs/module-specifiers.test.ts:39:6
error: TypeError: Import "not-in-the-map" not a dependency and not in import map from "file:///programs/module-specifiers.test.ts"
const nowhere = import.meta.resolve("not-in-the-map");
^

FAILURES

a bare specifier with no mapping is an error => ./programs/module-specifiers.test.ts:39:6

FAILED | 3 passed | 1 failed (1ms)

error: Test failed

No fallback, no directory search, no guess. If a bare name is not in the map and not already a dependency, resolution fails and says which name and why. This is the good kind of strictness: a resolver that searches can find the wrong thing and carry on, while one that looks in a single place either finds it or tells you. Pin the refusal:

Deno.test("a bare specifier with no mapping is an error", () => {
const error = assertThrows(
() => import.meta.resolve("not-in-the-map"),
TypeError,
);

assert(
error.message.includes(
'Import "not-in-the-map" not a dependency and not in import map',
),
);
});
a bare specifier with no mapping is an error ... ok (187µs)

resolution checks nothing

After the last step, predict what resolving ./sibling, extension missing, file nonexistent, does:

Deno.test("resolution checks nothing", () => {
assertThrows(() => import.meta.resolve("./sibling"), TypeError);
});
Check programs/module-specifiers.test.ts
running 5 tests from ./programs/module-specifiers.test.ts
...
resolution checks nothing ... FAILED (262µs)

ERRORS

resolution checks nothing => ./programs/module-specifiers.test.ts:52:6
error: AssertionError: Expected function to throw.

FAILURES

resolution checks nothing => ./programs/module-specifiers.test.ts:52:6

FAILED | 4 passed | 1 failed (2ms)

error: Test failed

Nothing threw. Resolving and loading are separate steps: ./sibling is a relative specifier, so resolving it is pure URL arithmetic that produces a perfectly good URL with nothing behind it, and the failure comes later, from whatever tries to fetch it. Correct the test to assert what actually happens:

Deno.test("resolution checks nothing", () => {
const missing = import.meta.resolve("./sibling");

assert(missing.endsWith("/programs/sibling"));
assert(!missing.endsWith(".ts"));
});
resolution checks nothing ... ok (31µs)

Worth knowing because it tells you how to read the two errors this page has produced. The TS2307 from the extension step is a loading failure reported at check time; the TypeError from the previous step is a resolution failure. Only the second one is about the specifier, and only bare specifiers can fail to resolve.

CommonJS still resolves

One last neighbour, programs/legacy.cjs, and its import:

module.exports = { fromCommonJs: 42 };
import legacy from "./legacy.cjs";
Deno.test("CommonJS still resolves", () => {
assertStrictEquals(typeof legacy, "object");
assertStrictEquals((legacy as { fromCommonJs: number }).fromCommonJs, 42);
});
CommonJS still resolves ... ok (22µs)

Importing a .cjs file from an ES module works, and what you get as the default export is that file's module.exports. Worth one line in your head for the day a dependency has not been converted. The type is any, which is why the read needs the cast, so nothing about it is checked: treat the boundary the way you would any other untyped input, with the discipline from the any, unknown, and never page.

what the ecosystem chapter maps to

Most of what is written about JavaScript module resolution is about package.json and node_modules. Deno has neither, so the translation is short:

ElsewhereHere
package.jsondeno.json
npm installdeno add
node_modules/a global cache, outside the project
package-lock.jsondeno.lock
"exports" and "imports" mapsimports in deno.json
a bundler, to resolve bare namesnothing; the runtime resolves them
a CDN URL, to skip installingjsr:, npm:, or a plain URL
.mjs and "type": "module"nothing; every file is a module

The right-hand column is one file and one command. If you are carrying knowledge from the left-hand column, most of it is not wrong here so much as unnecessary.

In practice