While it's certainly possible to use "dynamic" import()
statements, as described in this answer, there are benefits to avoiding that pattern by restricting your modules to using static import
statements. One such benefit, for example, is preserving static analysis of the module graph, which is performed by many tools in the JS ecosystem.
One way you can do that is to write your library code using abstractions on top of the expected runtimes and accessing those runtimes' features conditionally using feature-detection. Here's an example of what I mean by that:
env.mjs
:
export function getEnvAsObject () {
// Bun
if (typeof globalThis.Bun?.env === 'function') {
return globalThis.Bun?.env;
}
// Deno
if (typeof globalThis.Deno?.env?.toObject === 'function') {
return globalThis.Deno?.env?.toObject();
}
// Node
if (typeof globalThis.process.env === 'object') {
return globalThis.process.env;
}
// Handle unexpected runtime
return {};
}
That way, the runtime detail is abstracted away from the consumer when the time comes to import and use it:
module.mjs
:
import {getEnvAsObject} from './env.mjs';
const env = getEnvAsObject();
console.log(env);
# Run using Bun
bun module.mjs
# Run using Node
node module.mjs
# Run using Deno
deno run --allow-env module.mjs
The example env.mjs
module above doesn't use other imports as dependencies, so it's pretty straightforward.
In scenarios which require more complex code involving other imports, preserving static analysis might require offering different entrypoints for your library, based on runtime. I'll also provide an example of what I mean by that below.
In the example, let’s say we have a library that provides two functions. Each reads the text content of a file on disk.
- One returns an uppercase version of the text.
- One returns a lowercase version of the text.
Both also accept an AbortSignal
option to cancel the operation.
In TypeScript, the function signature for each of them might look like this:
(filePath: string, options?: { signal?: AbortSignal }) => Promise<string>
The first step would be to create a separate module for each runtime which requires a different import statement. In that module we create a common abstraction for reading a text file based on the signature above.
One for Node, where we import from Node's fs
module:
io.node.mjs
:
import {readFile} from 'node:fs/promises';
export function readTextFile (filePath, {signal} = {}) {
// Ref: https://nodejs.org/docs/latest-v18.x/api/fs.html#fspromisesreadfilepath-options
return readFile(filePath, {encoding: 'utf-8', signal});
}
One for Bun (this is simple because Bun uses Node's API, so we just re-export from the Node module):
io.bun.mjs
:
export * from './io.node.mjs';
And one for Deno, where no import statement is necessary because this functionality is in the Deno namespace:
io.deno.mjs
:
export function readTextFile (filePath, {signal} = {}) {
// Ref: https://doc.deno.land/deno/stable@v1.24.3/~/Deno.readTextFile
return Deno.readTextFile(filePath, {signal});
}
Then, the actual logic for the library functions can be written once in a style that's designed to be curried (which we'll get to in the next step):
io.mjs
:
export async function getUpperCaseFileText (readTextFile, filePath, {signal} = {}) {
const text = await readTextFile(filePath, {signal});
return text.toUpperCase();
}
export async function getLowerCaseFileText (readTextFile, filePath, {signal} = {}) {
const text = await readTextFile(filePath, {signal});
return text.toLowerCase();
}
Finally, we can create one entrypoint to the library for each runtime environment. This is where the core functions are curried and exported:
One for Node:
lib.node.mjs
:
import {readTextFile} from './io.node.mjs';
import {
getLowerCaseFileText as lower,
getUpperCaseFileText as upper,
} from './io.mjs';
export function getLowerCaseFileText (filePath, {signal} = {}) {
return lower(readTextFile, filePath, {signal});
}
export function getUpperCaseFileText (filePath, {signal} = {}) {
return upper(readTextFile, filePath, {signal});
}
One for Bun (again, just re-exporting from the Node module):
lib.bun.mjs
:
export * from './lib.node.mjs';
And one for Deno:
lib.deno.mjs
:
import {readTextFile} from './io.deno.mjs';
import {
getLowerCaseFileText as lower,
getUpperCaseFileText as upper,
} from './io.mjs';
export function getLowerCaseFileText (filePath, {signal} = {}) {
return lower(readTextFile, filePath, {signal});
}
export function getUpperCaseFileText (filePath, {signal} = {}) {
return upper(readTextFile, filePath, {signal});
}
I hope it's apparent how the currying is very little extra code, and that the only real difference between these entrypoints is the import specifier that's specific to each runtime. This allows the code to be statically-analyzable, which provides lots of benefits!
If someone wanted to use this contrived library, the only thing they'd need to change is the runtime name in the static import specifier:
module.mjs
:
// import {getUpperCaseFileText} from './lib.bun.mjs'; // when using Bun
// import {getUpperCaseFileText} from './lib.node.mjs'; // when using Node
import {getUpperCaseFileText} from './lib.deno.mjs'; // when using Deno
const text = await getUpperCaseFileText('./lib.bun.mjs');
console.log(text); // logs => "EXPORT * FROM './LIB.NODE.MJS';"
and run it in the respective runtime:
bun module.mjs
node module.mjs
deno run --allow-read module.mjs
It might seem like a bit more work, but publishing your library using only static imports allows your consumers to enjoy all the same benefits of static analysis, and — conversely — by publishing a library which uses dynamic import()
, you will deprive consumers of those benefits.