Static Imports Are Undermining JavaScript’s Isomorphism

Published: (February 24, 2026 at 11:51 PM EST)
4 min read
Source: Dev.to

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

MechanismWhat it controls
Import mapsSpecifier resolution at load time (host‑level).
package.json exportsEntry points per environment (package‑level).
BundlersGraph optimization at build time.
Composition root + DIWhich 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.

0 views
Back to Blog

Related posts

Read more »

My Developer Portfolio — Umer Azmi

Hi, I'm Umer Azmi, a Frontend Developer and Python Developer from Mumbai, India. Projects and Contributions 👉 https://github.com/UmerAzmihttps://github.com/Ume...