fynjs API
    Preparing search index...

    Module optional-import

    optional-import

    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.

    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.

    Returns an optional import function bound to the caller's import.meta.

    • optionalImport(specifier, optsOrMsg?) → Promise of the module namespace
    • optionalImport.resolve(specifier, optsOrMsg?) → resolved URL, synchronously
    • optionalImport.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, replaceable

    Standalone forms, for when you do not want to build a bound function.

    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 }.

    It 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.
    • Registering loader hooks from inside the graph. 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

    ImportMetaLike
    LogFunction
    NotExportedHandling
    OptionalImportFunction
    OptionalImportOpts
    OptsOrMessage
    makeOptionalImport
    setDefaultLog
    tryImport
    tryResolve