وب مدرن، رابط کاربری و تجربهی وب · فصل ۳ — Type System، Runtime، Async و Build · 42
Node.js ESM and Module Resolution
Predict and verify how a pinned Node runtime classifies and resolves a bounded ESM import, then distinguish package-interface, specifier, and format failures from TypeScript or bundler behavior. میتواند «Node.js ESM و Module Resolution» را در یک feature محدود طراحی/پیادهسازی کند و correctness، accessibility یا failure behavior مرتبط را اثبات کند.
Lesson summary
Predict and verify how a pinned Node runtime classifies and resolves a bounded ESM import, then distinguish package-interface, specifier, and format failures from TypeScript or bundler behavior.
میتواند «Node.js ESM و Module Resolution» را در یک feature محدود طراحی/پیادهسازی کند و correctness، accessibility یا failure behavior مرتبط را اثبات کند.
This lesson produces
میتواند «Node.js ESM و Module Resolution» را در یک feature محدود طراحی/پیادهسازی کند و correctness، accessibility یا failure behavior مرتبط را اثبات کند.
Who it is for
Foundation
Worked baseline, explicit evidence table, safe local fixture and one guided failure probe.
Repair
Reduce to one failing contract, observe evidence, repair, then restore full case.
Experienced
Skip syntax walkthrough; require independent implementation, mutation evidence and residual-risk statement.
Prerequisites
When “the import looks right” is not evidence
A project opens cleanly in an editor. Autocomplete finds the dependency. A development tool starts. Then a Node script runs directly and the import fails. The useful question is not “why is Node being picky?” The useful question is: what exact runtime contract did Node apply to this specifier, in this package scope, under this module format?
This lesson owns that runtime contract. You will build a Node ESM Resolution Trace v1 for one bounded fixture. The trace must separate four things that are often collapsed into one vague idea of “module resolution”: module format, specifier class, package interface, and the runtime result. You will predict a failure before execution, observe it in a pinned Node runtime, repair only the failing boundary, and then repeat the method in a changed context.
The target is not a list of ESM tricks. The target is a repeatable diagnosis method. An editor, TypeScript configuration, or bundler can be useful tooling, but success there is not runtime proof. In this lesson, the Node process is the observation surface.
Scope, prerequisite, and route
WEB-001 is the hard conceptual prerequisite. Reuse its bounded TypeScript evidence when you need to distinguish a compile-time/editor signal from a Node runtime signal; do not re-teach TypeScript configuration here. WEB-003 owns async behavior, and WEB-014 owns Vite bundling and its production asset graph. Those concerns may appear in the scenario, but they are not the completion target for WEB-002.
Your completion artifact is Node ESM Resolution Trace v1. A satisfactory trace records: the importing file and package scope; the runtime module marker or file context; the import specifier exactly as written; the specifier class; any relevant exports or imports mapping; the predicted resolved target or predicted failure class; the observed Node result; the repair; and one residual limitation.
Foundation route: keep the classification checklist visible while you work. Repair route: isolate one failing row, rerun it, then restore the full fixture. Experienced route: skip syntax review, but do not skip the prediction, mutation, runtime evidence, or transfer task.
The six-step resolution trace
Use this mental model before changing code:
Import specifier → package scope / format marker → specifier class → package interface rules → resolved URL and format → load result.
- Import specifier. Copy the string exactly.
./local.js,demo-pkg/public, and#configare not interchangeable shapes. - Package scope and module format. Determine the file/package context that tells Node how the source is interpreted. Explicit markers such as
.mjs,.cjs, and the nearest relevantpackage.json"type"are high-value evidence. Do not infer format from editor coloring. - Specifier class. A relative or absolute URL-like specifier follows URL-style resolution. A bare specifier enters package resolution. A specifier beginning with
#is a package-import specifier. Classify first; debug second. - Package interface. For a bare package import, inspect the package boundary. An
exportsmap can define which entry points/subpaths are public to resolution. The fact that a file physically exists undernode_modulesis not enough to prove that the package exposes that path. - Resolved URL and format. Node resolution is URL-based for ESM. For relative/absolute file specifiers, do not assume extension searching or directory-index fallback. Record the target Node actually resolves—or the specific boundary at which resolution fails.
- Load result. Resolution and loading are related but not identical. A URL may resolve yet fail when Node determines that the file/protocol/format cannot be loaded. Your trace should not label every failure “module not found.”
The diagram specified as WEB-002-A1 represents this chain. Its text fallback is sufficient to complete the lesson if no visual asset is rendered.
Foundation: four contracts you must keep separate
1. Module-format contract
Node supports both CommonJS and ECMAScript modules. For this lesson, treat format as a runtime classification problem. Start with explicit evidence: .mjs means an ES module; .cjs means CommonJS; and "type": "module" or "type": "commonjs" in the relevant package scope affects .js files. Current Node can also detect module syntax in some marker-less .js cases, but that does not justify designing ambiguous package boundaries. The lab keeps the boundary explicit so the diagnosis remains inspectable. [S-NODE-ESM]
A common debugging error is to “fix” an import statement while the file is being interpreted under a different module system. That changes the wrong layer. First write down how Node is classifying the importing file.
2. Relative-specifier contract
In Node ESM, relative and absolute file specifiers require a file extension. ./local is not a request for “whatever file named local Node can guess.” The explicit target is part of the ESM contract. Directory indexes must also be fully specified. [S-NODE-ESM]
This is why a path that works under a tool with extension inference can fail when the same source is executed directly by Node. The correct evidence is the exact runtime command and result, not the development tool's prior success.
3. Bare-package contract
demo-pkg and demo-pkg/public are bare package specifiers. Their behavior is governed by package resolution and, when present, the package's public mappings. If a package declares exports, a subpath not exported by that map is not a supported ESM entry point merely because a matching file exists on disk. [S-NODE-PACKAGES]
Treat exports as a package interface constraint, not as a universal security boundary. It shapes supported resolution paths. It does not prove that the underlying file is inaccessible by every mechanism or threat model.
4. Runtime-evidence contract
A green editor underline, TypeScript language-service success, or bundler success can inform a hypothesis. None of them replaces the observation required here: run the pinned Node process and record its output/error class. The artifact must explicitly distinguish observation (“Node exited non-zero and reported a package-path export failure”) from inference (“the bare subpath violates the dependency's package interface”).
Deep explanation: why the same text can resolve differently
Suppose main.js contains:
import { localValue } from './local';
import { publicValue } from 'demo-pkg/public.js';
Two independent contracts can fail.
The first specifier begins with ./, so it is a relative specifier. Under ESM, Node applies URL-style resolution and does not silently try .js for you. If the code intends local.js, the missing extension is the defect.
The second specifier is bare. It crosses a package boundary. Imagine the dependency declares:
{
"name": "demo-pkg",
"type": "module",
"exports": {
".": "./index.js",
"./public": "./public.js"
}
}
Here the package exposes demo-pkg/public, not demo-pkg/public.js. The physical file public.js exists, but the specifier must match the public interface declared by exports. Repairing the first import does not repair the second.
This gives you a useful diagnostic rule: the visible text after from is only the first input to resolution. Package scope, specifier class, and package interface all influence the result.
Node's ESM resolution algorithm can be reasoned about as a decision tree. A valid URL is handled as a URL; ./, ../, or / enters relative/absolute URL resolution; # enters package-import resolution; otherwise the specifier is treated as a bare package specifier. For file URLs, Node then checks conditions such as existence/directory state and determines a module format for loading. [S-NODE-ESM-RESOLVE]
Do not turn that algorithm into cargo-cult steps. The point of the trace is to stop guessing. Each row should answer: “Which branch did this specifier enter, what evidence says so, and what did Node actually report?”
Worked example: from two failures to one clean runtime trace
The starter fixture intentionally contains the two imports above. Before executing it, write two predictions:
./localshould fail at the relative-specifier boundary because the explicit extension is missing.demo-pkg/public.jsshould fail at the package-interface boundary if the package exposes only./public.
Run the fixture exactly as instructed in lab://WEB-002/WEB002-LAB1. Record the Node version, working directory, command, exit code, and the first useful error classification. Do not paste a huge stack trace as your analysis.
Repair only the relative boundary first. Change the specifier so it explicitly targets the intended local file. Run again. If the next failure moves to the dependency import, that is useful evidence: one independent contract has been repaired and another is now visible.
Then inspect the dependency's package.json. Compare the requested subpath with the declared exports keys. Repair the specifier to use the public subpath already exposed by the package; do not delete exports merely to make the error disappear. Run again and record the successful output.
The evidence table WEB-002-TAB1 should now include at least these rows: module-format context, relative specifier, bare package subpath, runtime result, mutation result, and residual limitation. A strong row contains an input, prediction, observation, diagnosis, bounded repair, and post-repair observation.
Failure modes: deliberate mutations that expose false confidence
Mutation A — remove the relative extension
Starting from a working baseline, change ./local.js back to ./local. Predict the failure before running it. The expected diagnosis is not “ESM is broken”; it is “the relative ESM specifier no longer names the file explicitly.” After observation, restore only that specifier and rerun.
Mutation B — close the package subpath
Starting from a working baseline, change the dependency's exports so the previously used public subpath is no longer exposed. The file can remain physically present. Predict what should happen to a bare import of that subpath, run Node, and record the observed package-interface failure. Restore the package interface and verify recovery.
Mutation C — change the module-format marker
Change the app package's "type" from "module" to "commonjs" while leaving ESM syntax in a .js entry file. Predict how this changes the runtime classification boundary. Run it. Diagnose the format mismatch instead of rewriting unrelated package paths.
These mutations are deliberately separate because one generic “imports failed” label hides the skill. Your trace must identify the critical dimension that each mutation attacks: specifier_class, package_interface, or module_format.
Four shortcuts are explicitly out of scope and should be rejected if they appear in your reasoning: treating Node, TypeScript and bundler resolution as identical; treating editor success as runtime proof; treating exports as a strong security boundary; and assuming that adding an extension fixes every ESM failure.
Experienced bridge: diagnose from the boundary, not from the toolchain
If you already work with TypeScript, package managers and bundlers, compress the syntax review and use this stricter workflow:
- Capture the runtime command and exact importing file.
- Identify the nearest relevant package scope and explicit format markers.
- Classify every failing specifier before editing it.
- For bare/package-import specifiers, inspect the package interface before browsing the file tree.
- Use runtime evidence to test one hypothesis at a time.
- Keep the repair minimal enough that a changed error location is interpretable.
- Re-run a mutation after baseline success; otherwise you have not shown that your evidence can detect the failure.
This is especially valuable in monorepos. Two files with similar source can live under different package scopes. A build tool can rewrite or bundle specifiers that Node would not resolve from the original source. A package can expose a stable entry point while keeping internal file names free to change. Experienced practice is not memorizing more exception cases; it is preserving the diagnostic layers.
Alternatives and trade-offs
.mjs versus "type": "module"
Both can provide explicit ESM intent. .mjs makes the file-level signal obvious. "type": "module" establishes a package-wide default for .js files. The better choice depends on package organization and compatibility constraints. The lesson does not declare a universal winner; it requires that the chosen marker be explicit in the trace.
Public subpath versus internal file path
When a package exposes demo-pkg/public, prefer the declared package entry point over reaching into an internal filename. The public subpath expresses the dependency's intended interface and is less coupled to internal layout. That is an API-design/resolution argument, not a claim that exports alone provides a security sandbox.
Fix the runtime source versus rely on bundler rewriting
A bundler may legitimately transform module graphs for a bundled application. But if the artifact you are trying to run is a direct Node script, a bundler-specific success does not satisfy this lesson. Choose the mechanism for the actual execution context. If the production contract is “always bundled,” document that elsewhere; do not silently transfer that assumption to a direct Node runtime fixture.
Guided practice, then changed-context transfer
Guided baseline
Open WEB002-LAB1/learner/starter. Without editing, fill the first rows of the evidence template:
- importing file and package scope;
- format marker;
- exact relative specifier and class;
- exact package specifier and class;
- relevant
exportskeys; - predicted first failure.
Run the starter. Compare the observation with the prediction. Repair one boundary at a time until the baseline prints the expected local and dependency values. Then run the mutation probe against your repaired starter and record at least one predicted/observed mutation.
Independent transfer
Move to WEB002-LAB1/learner/transfer. The changed fixture uses a different local helper and a different dependency interface. Do not copy the literal baseline specifiers. Repeat the method: determine format context, classify the specifiers, inspect the dependency interface, predict, execute, repair, and record evidence. Completion requires the transfer case to run successfully without the decisive fix being supplied by the lesson text.
Your answer is not the final code alone. The transfer evidence must explain why the repaired specifier is compatible with the changed contract.
Verification: what counts as evidence
A Node ESM Resolution Trace v1 record should make another reviewer able to reconstruct your conclusion. At minimum, keep these fields inspectable:
- environment: Node version and command;
- package scope: relevant
package.jsonpath andtypevalue; - specifier: exact source text and classified kind;
- package interface: relevant
exports/importsentry or “not applicable”; - prediction: expected target or failure boundary before execution;
- observation: exit code and concise runtime result;
- diagnosis: one critical dimension, not a vague toolchain label;
- repair: the smallest changed boundary;
- re-observation: proof that the intended behavior returned;
- mutation: predicted and observed deliberate failure;
- transfer: changed-context runtime evidence;
- residual limitation: what this fixture does not prove.
Examples of residual limitations: the fixture does not prove behavior under a bundler; it does not cover every conditional export combination; it does not establish a package security boundary; it does not prove behavior on Node versions you did not run.
Keep facts and inferences separate. “The process exited with a package-path-not-exported error” is an observation. “The requested bare subpath is outside the package's declared public interface” is a diagnosis supported by the package metadata and runtime result.
Assessment and completion bar
Five assessments cover retrieval, application, diagnosis, decision and transfer. The blocking tasks are application, diagnosis and transfer. You complete the lesson when the baseline works in the pinned Node runtime, a deliberate mutation is detected and repaired, and the changed-context transfer works with an evidence trace that states one residual limitation.
Do not expose a hidden answer key. The evaluator should inspect the artifact and the runtime evidence. A visually plausible file tree or an editor screenshot is not sufficient.
The human accountability boundary remains: AI can suggest candidate checks after you state the contract and evidence need, and it can help summarize observations. It must not fabricate a Node run, invent an error that was not observed, or replace your engineering judgment about the package interface.
References and freshness boundary
This lesson uses the exact frozen source set for WEB-002: Node's official ESM documentation, official Packages documentation, and the ESM resolution/loading algorithm section. The frozen retrieval date is 2026-09-02 and the review-due date is 2026-10-17. A 2026-09-10 authoring spot-check confirmed that the bounded rules used here remain represented in the official documentation; the source set was not expanded.
Use [S-NODE-ESM] for module markers, specifier classes, mandatory relative/absolute extensions and URL-based ESM behavior; [S-NODE-PACKAGES] for package exports/imports boundaries; and [S-NODE-ESM-RESOLVE] for the resolution/loading decision model. Re-review the volatile claims when the review date arrives or when the lab stops matching the pinned runtime behavior.
Assessment
There is no answer key in this lesson pack; answer it yourself and mark honestly. "Needs review" sends the lesson to your review queue.
WEB-002-S1 retrieval
State the four-part WEB-002 runtime contract: module format, specifier class, package interface, and runtime trace. Name one thing editor or bundler success does not prove.
WEB-002-S2 application
Repair WEB002-LAB1 baseline so direct Node execution succeeds. Record package format context, exact specifier classes, relevant exports mapping, prediction, observations, bounded repairs, and final runtime result.
WEB-002-S3 diagnosis
From a working baseline, run one deliberate mutation: remove a required relative extension, close the used package subpath in exports, or change the module-format marker. Predict first, execute, diagnose one critical dimension, repair only that boundary, and re-run.
WEB-002-S4 decision
Choose an explicit ESM marker strategy for a small Node package and defend it. Then decide whether to import a dependency through its declared public subpath or a physical internal filename. State evidence that could change each decision.
WEB-002-S5 transfer
Independently repair the changed-context fixture in WEB002-LAB1/learner/transfer. Reclassify its local and package specifiers from the new metadata, run direct Node evidence, and record one residual limitation.
Assessment rubric
Critical dimensions: module_format, specifier_class, package_interface, runtime_trace.
- Contract correctness — critical: implementation and explanation preserve Node runtime ESM classification/resolution boundaries.
- Evidence quality — critical: prediction, direct Node observation, mutation, repair and transfer are traceable; editor/bundler success is not substituted for runtime evidence.
- Scope discipline — critical: TypeScript compilation, async behavior and bundler graph concerns are deferred to their owning lessons.
- Transfer independence — critical: the changed-context fixture is solved without a decisive scaffold.
Fail if a critical mutation is not detected, runtime evidence is fabricated/omitted, or the hard prerequisite/chapter boundary is violated.
References & freshness
Case studies
Lab files
Purpose: diagnose direct Node ESM resolution using a local, non-destructive fixture. No network is required.
Pinned runtime: Node v22.16.0. Run node --version before collecting evidence. If it is not exactly v22.16.0, stop: that run is not valid evidence for this frozen lab.
- Record both
required_node_version=v22.16.0and your observednode --versionin the evidence file. - In
starter, inspectpackage.json,main.js, andnode_modules/demo-pkg/package.json. Predict the first failure before execution. - Run
node main.js. Repair one owned boundary at a time until the baseline succeeds. - From the lab root, run
python mutation_probe.py --submission-dir learner/starter. The probe mutates temporary copies only. Record at least one prediction/observation and use itsstderr_classificationas runtime evidence when relevant. - Repair the independent
transferfixture without copying literal baseline assumptions. Runnode main.jsthere. - Complete
evidence/learner_evidence.jsonusing the public trace schema.
Do not delete package-interface metadata just to silence an error. Do not use editor, TypeScript or bundler success as a substitute for the direct Node run.
Lesson media & slides
Teaching files (download)