Static Imports Are Undermining JavaScript’s Isomorphism
Source: Dev.to
TL;DR
- Static imports bind dependencies at module‑load time.
- Early binding encodes platform assumptions.
- Declared dependencies move those decisions to the composition root.
- This is not a new module system; it is standard Dependency Injection applied at the module level.
JavaScript runs natively in both the browser and on the server, making true isomorphism possible. Yet modern JavaScript architecture often works against it.
import fs from "node:fs";
This line embeds a Node‑only capability directly into the module. A browser cannot satisfy "node:fs" by default, so the module is no longer isomorphic.
The issue is not fs; the issue is early binding. Static imports resolve dependencies during module evaluation, fixing the graph before your code runs. If a dependency is platform‑specific, the module becomes platform‑specific.
Making dependencies explicit
Instead of binding immediately, a module can declare what it needs.
// user-service.mjs
export const __deps__ = {
fs: "node:fs",
logger: "./logger.mjs",
};
export default function makeUserService({ fs, logger }) {
return {
readUserJson(path) {
const raw = fs.readFileSync(path, "utf8");
logger.log(`Read ${raw.length} bytes`);
return JSON.parse(raw);
},
};
}
The module imports nothing directly. It declares a dependency contract and receives concrete implementations from the outside. This is Dependency Injection applied at the module level; the composition root decides what gets passed in.
Manual composition root
Node
// node-entry.mjs
import fs from "node:fs";
import logger from "./logger.mjs";
import makeUserService from "./user-service.mjs";
const service = makeUserService({ fs, logger });
Browser
// browser-entry.mjs
import fsAdapter from "./browser-fs-adapter.mjs";
import logger from "./logger.mjs";
import makeUserService from "./user-service.mjs";
const service = makeUserService({
fs: fsAdapter,
logger,
});
The module itself does not change; only the composition root changes. Platform decisions stay at the edge of the system, and because dependencies are injected explicitly, tests can pass fakes directly instead of mocking module imports.
Automating composition
Because the contract is exposed via __deps__, the composition root can be made data‑driven:
// link.mjs
export async function link(entrySpecifier, overrides = {}) {
const mod = await import(entrySpecifier);
const depsSpec = mod.__deps__ ?? {};
const deps = {};
for (const [name, specifier] of Object.entries(depsSpec)) {
const finalSpecifier = overrides[specifier] ?? specifier;
const imported = await import(finalSpecifier);
deps[name] = imported.default ?? imported;
}
return mod.default(deps);
}
Node
const service = await link("./user-service.mjs");
Browser
const service = await link("./user-service.mjs", {
"node:fs": "./browser-fs-adapter.mjs",
});
Binding becomes explicit program logic, not loader side effects.
How this differs from import maps and exports
| Mechanism | What it controls |
|---|---|
| Import maps | Specifier resolution at load time (host‑level). |
package.json exports | Entry points per environment (package‑level). |
| Bundlers | Graph optimization at build time. |
| Composition root + DI | Which concrete capabilities a module receives at runtime (application‑level). |
- Import maps answer: Where is this module?
- Composition root answers: Which capability does this module receive?
Different layers, different concerns.
Trade‑offs
- Reduced static analyzability and tree‑shaking precision.
- TypeScript integration becomes more manual.
- Unnecessary for small or purely single‑runtime apps.
- Introduces architectural discipline (composition root).
This is a tool, not a default.
When to use it
Use it when:
- You need true cross‑runtime modules (Node, browser, edge).
- You want environment decisions centralized.
- Testability without heavy mocking is important.
- You prefer explicit capability boundaries.
Do not use it when:
- Your app targets a single runtime.
- Build‑time optimization and tree‑shaking are primary concerns.
- Simplicity outweighs architectural flexibility.
Static imports are not wrong—they are efficient and idiomatic. But they bind early, encoding platform assumptions. If we care about preserving JavaScript’s isomorphism, we should be deliberate about where binding happens.