ESM optional dependency loading that tells "not installed" apart from "installed but broken".
The ESM counterpart to optional-require. Where optional-require wraps a require call,
there is no call to wrap in ESM — a static import is resolved and linked for the entire
reachable graph before any of your code evaluates. Optional loading therefore has to go through
import(), which makes it async.
fyn add optional-import
import { makeOptionalImport } from "optional-import";
const optionalImport = makeOptionalImport(import.meta);
// undefined only if chalk is NOT INSTALLED
const chalk = await optionalImport("chalk");
// with a fallback
const chalk = await optionalImport("chalk", { default: plainFormatter });
// synchronous availability check — no await, no evaluation
if (optionalImport.has("chalk")) {
/* ... */
}
Pass import.meta, not import.meta.url. The single-argument form of
import.meta.resolve has no parent parameter, so the bound resolve function is the only
carrier of your module's location. Passing the url string throws a TypeError.
import()Because Node raises the same error for a missing dependency and for a dependency that is installed but whose own dependency is missing:
import("totally-not-installed") -> ERR_MODULE_NOT_FOUND
Cannot find package 'totally-not-installed' ...
import("broken-nested") -> ERR_MODULE_NOT_FOUND
Cannot find package 'a-dep-that-is-not-installed' ...
Same code, and the error carries no structured field naming the specifier that failed — the
message even names the nested one. So a try/catch around import() cannot tell them apart,
and a genuinely broken install silently degrades into your fallback, presenting as "feature
unavailable" rather than "your install is broken".
optional-import resolves first and imports second:
let url;
try {
url = meta.resolve(specifier); // only ever fails for `specifier` itself
} catch (err) {
return handleNotFound(err);
}
return await import(url); // any throw here is REAL — propagate it
meta.resolve never fails on behalf of a nested specifier, which makes the distinction
structural rather than a guess based on error message text.
// installed, but its own dependency is missing — throws, does NOT return "FALLBACK"
await optionalImport("broken-nested", { default: "FALLBACK" });
That premise — resolve fails when the thing is not there — holds for bare specifiers only.
For a path or file: URL, Node's ESM resolver does no filesystem check, so resolve always
succeeds and the missing-file signal would arrive from import(), where it is once again
indistinguishable from a broken module.
So path and file: URL specifiers get an explicit existence check after resolving, which
restores the same split:
// absent file → the fallback, just like a bare specifier that is not installed
await optionalImport("./optional-config.js", { default: {} });
await optionalImport("/etc/myapp/plugin.mjs", { default: null });
// the file IS there but it throws, or its own import is missing → still a real error
await optionalImport("./broken-plugin.js", { default: {} }); // throws
.has() and .resolve() agree with this: an absent path reports false / the fallback rather
than a URL to a file that is not there.
Only the literal resolved URL is checked. ESM has no directory resolution and no extension
probing, so unlike optionalRequire, "./foo" does not fall back to ./foo.js or
./foo/index.js — Node would not find those either, so the specifier is simply absent. A path
that is a directory counts as present and then fails on import with
ERR_UNSUPPORTED_DIR_IMPORT, which is a real error worth seeing rather than a silent fallback.
See the full API reference for every option, type, and runtime rule.
makeOptionalImport(meta, log?)Returns an optional import function bound to the caller's import.meta.
optionalImport(specifier, optsOrMsg?) → Promise of the module namespaceoptionalImport.resolve(specifier, optsOrMsg?) → resolved URL, synchronouslyoptionalImport.has(specifier) → boolean, synchronously. It throws, like .resolve(),
when resolving fails for a reason other than "not installed" (for example an invalid
package.json), so a broken install is never reported as absent.optionalImport.log — the log function, replaceabletryImport(meta, specifier, optsOrMsg?) / tryResolve(meta, specifier, optsOrMsg?)Standalone forms, for when you do not want to build a bound function.
setDefaultLog(log)Replace the default log function (console.log) used when no other is given.
Mirrors optional-require:
| option | description |
|---|---|
default |
value returned when the module is not installed |
notFound(err) |
called instead of returning default when the module is not installed |
fail(err) |
called when the module resolved but importing it threw; otherwise the error is rethrown |
message |
true for a default not-found message, or a string to prepend |
log |
log function for this call |
meta |
override the bound import.meta for this call |
notExported |
"notFound" (default) or "fail" — how to treat ERR_PACKAGE_PATH_NOT_EXPORTED |
As a shorthand, optsOrMsg may be a string or true, equivalent to { message }.
optional-requireIt is async. There is no synchronous ESM equivalent. optionalImport.resolve() and
.has() are synchronous, because resolution does not evaluate the module — that covers
availability checks, which is often the whole question.
It returns the module namespace unmodified. For a CJS optional dependency, module.exports
lands on .default. .default is deliberately not auto-unwrapped, because that would hide the
named exports of a real ESM package.
meta.resolve does not stat the filesystem. It performs resolution, not an existence
check — it fails when a bare package cannot be located, or when an exports map refuses a
subpath. Path and file: URL specifiers are therefore existence-checked separately, as
described under Path specifiers. One gap remains either way: a subpath of
an installed package with no exports map ("some-pkg/nope.js") is a bare specifier, so it
resolves without error and surfaces as a fail on import rather than a notFound.
For the same reason, .has() returns true for it and .resolve() returns its URL.
Conditions differ from optional-require. Resolution here runs under the import
condition, so a package with divergent conditional exports may resolve to a different file than
optionalRequire would load.
Top-level await has a cost. Awaiting an optional import at module scope makes your module
async, and require() of an ESM graph containing top-level await throws
ERR_REQUIRE_ASYNC_MODULE. If your package has CJS consumers relying on require(esm), call
this from inside an async function instead, or stay on optional-require.
imports/exports fallback arrays. {"#opt": ["maybe-missing", "./stub.js"]} looks
purpose-built for this and does not fall back — a missing package throws
ERR_MODULE_NOT_FOUND rather than moving to the next entry.import "./register-hooks.js" followed by
import x from "maybe-missing" in the same module still throws, because linking precedes all
evaluation. Hooks must be installed via --import or a separate entry.Apache-2.0