Skip to content

Provide some mechanism to conditionally and synchronously import modules (or just builtins) from ESM #52599

Closed
@jakebailey

Description

@jakebailey

What is the problem this feature will solve?

Thanks to #51977, requiring ESM is looking to be a real possibility. As such, TypeScript is considering transitioning over to ESM in the near future (depending on when require(ESM) is unflagged, hopefully in time for TS 6.0?), as that sort of change would no longer pose compatibility problems for the vast number of downstream CJS users. This has a number of benefits, mainly that we could finally share code between tsc.js and our public API without a startup perf regression, and that we wouldn't be duplicating code in our package (thankfully only two copies remain as of TS 5.5, down from six copies in TS 4.9).

TypeScript's current public API bundle is intentionally "UMD-ish", detecting whether or not module.exports exists and using it (declaring a global otherwise), then later conditionally requiring built-in modules like fs if we believe to be running within Node. This allows us to ship one single bundle that works in Node, browsers, and bundlers alike.

However, the code that relies on conditional require is executed at the top-level as it's constructing the ts.sys object, the default "system" implementation for most of our APIs. Within CJS, this is fine, but within ESM, the only way to conditionally import something is by either:

  • Using top-level await (or doing it later asynchronously).
  • Importing createRequire from node:module.

Using top-level await breaks require(ESM), the whole reason we think we can use ESM in the first place, and TS is infamously not async and couldn't import it later. Importing node:module is a moot choice, since if we could safely import node:module, we could have just imported node:fs and so on.

So, we need some mechanism to synchronously import modules, or at least the builtins.

Given require(ESM) is now possible, it sure seems like there could be a way to safely synchronously import modules in ESM that are already require-able from CJS after #51977.

What is the feature you are proposing to solve the problem?

After discussing this in the TC39 Module Harmony meeting (with @guybedford @joyeecheung @JakobJingleheimer, others), there seemed to be a number of different paths forward:

  • Node or ECMAScript invent a new "weak" or "optional" import attribute (e.g. import fs from "node:fs" with { optional: true }) could be added; if the module fails to resolve, the imports are all undefined. This may require some sort of TC39 specing or proposal.
    • In the meeting this, this seemed palatable, though potentially had a too-long time horizon to be helpful for TS or other projects trying to do the same thing as us.
  • Node can add import.meta.require. This was previously proposed at Pull request opened for import.meta.require on core modules#130, but unfortunately drags CJS into the ESM world (potentially no more than createRequire, I suppose).
    • In the meeting, this seemed "okay" in that it's something Node could offer "now", but less desirable than other options.
  • Node (+ whoever owns the import.meta spec) can add import.meta.importSync or import.now, which is effectively just await import(...) that only works on sync-loadable ESM.
    • In the meeting, this seemed less palatable than other options without a more general use case or more supporting examples.
  • Node can add import.meta.builtins or similar (e.g. on process), which just provide access to Node's builtin modules. Largely, TS only needs fs, path, os, etc, so this would sidestep the "sync import" problem altogether. TS also conditionally imports source-map-support, so that would not work, though only in development. Thankfully, since one could get node:module this way, you can also shim require via createRequire, which is pretty neat.
    • In the meeting, this seemed pretty palatable. There was mention of this being something useful for WinterCG or similar to WASM, but I'm not very qualified to fully understand that one. There was also discussion about whether or not it would be all-getters, since people do want to patch / mock builtins.

What alternatives have you considered?

TS could also use package.json import maps to achieve "conditional" imports of Node-specific code, e.g. have an import like #system which in the Node condition imports from Node, but is shims otherwise. This seems to have a number of downsides in my view, specifically:

  • TS is bundled and our outputs are not associated with our inputs, so actually writing said code may be pretty challenging.
  • There are platforms other than Node that implement enough of the Node builtins to be compatible. Would they set the node condition? Would TS need to explicitly add mappings for each of these? What happens if our code is bundled? These gotchas make me feel like this would be too difficult to deal with.
  • Do import maps work when TS is loaded via a browser? Our intent is that we can go down to having only one copy of our code, but I don't think browsers understand what to do with package.json import maps. In the meeting, @JakobJingleheimer mentioned that one could remap imports like node:fs to shims, even to data:... blobs, but I'm definitely not experienced enough in browser ESM to know how to do that.

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature requestIssues that request new features to be added to Node.js.loadersIssues and PRs related to ES module loaders

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions