Description
Background
When a user writes a module specifier (the string literal after from
in an import declaration) in a TypeScript file, how should the compiler resolve that string to a file on disk to be included in type checking? Because TypeScript never rewrites module specifiers in its JavaScript emit, the only possible answer is that it should mirror whatever resolution behavior the code’s intended runtime module resolver has. I’m using “runtime module resolver” to mean the system whose module resolution behavior has observable effects at runtime: it may be a component of the runtime itself, as in Node, or it may be a bundler that consumes the module specifiers to produce one or more script files. (The “runtime” distinction is made to exclude analysis tools like linters, which may perform module resolution without having any impact on runtime behavior.) TypeScript’s way of handling this has been to say that the user must indicate what their code’s runtime module resolver is via the moduleResolution
compiler option so the compiler can mirror it.
As little as five years ago, there were only two places JavaScript could run that were worth mentioning: in Node as CommonJS modules, and in the browser as scripts. For the former, TypeScript had --moduleResolution node
. (The latter needed no moduleResolution
mode, though you can argue some sort of none
value would have been appropriate.) However, bundlers like Webpack were widely used and were themselves module resolvers, perhaps deserving their own moduleResolution
setting according to TypeScript’s philosophy. But demand for bundler-specific module resolution was essentially nonexistent, because bundlers mostly just copied Node’s module resolution algorithm, so users were able to get by with --moduleResolution node
.
(Note: as of this writing, --moduleResolution node16
and --moduleResolution nodenext
are identical in TypeScript. The latter is intended to be updated as Node changes. For brevity, I use node16
in this writing, but both are equally applicable everywhere.)
Over the next few years, though, the landscape changed. Browsers adopted ESM as a natively supported format, and Node added ESM support alongside CJS, with a complex interop system and new features like package.json exports
. A new wave of bundlers and runtimes emerged, and many adopted some of the features that Node introduced. But this time, none was similar enough to Node to piggyback on TypeScript’s --moduleResolution node16
option without users noticing problems.
Today
In this new landscape, users have been trying both node
and node16
with bundlers and browsers and hitting walls, which I will explore in some detail. In brief, the JavaScript ecosystem is in a phase where we cannot hope to provide a dedicated moduleResolution
mode for every runtime and bundler. At the same time, we have resisted allowing resolver plugins for many reasons:
- security and performance concerns
- implementing correct module specifier generation for a given resolution mode for declaration emit, auto-imports, and path completions is extremely non-trivial
- concerns about reliability under file-watching modes (a resolution must return not just what it found, but where it looked and found nothing, and where it looked up auxiliary information used in resolution such as package.json files)
- we would like to avoid allowing bespoke per-project resolution simply to reduce chaos
This philosophy has brought TypeScript to a point where we have avoided some significant pitfalls, but have essentially no support for module resolvers that are not Node. To make the issues explicit, let’s examine some hypothetical case studies.
Bundling with Webpack, esbuild, or Vite
These bundlers use a Node-CJS-like resolution algorithm and support package.json exports
. If the user chooses --moduleResolution node
, any of their dependencies that modify their export structure via package.json exports
will be misrepresented by TypeScript—imports from that package may not resolve, they may resolve to incorrect files, and they will receive incorrect auto-imports and path completions. If the user chooses --moduleResolution node16
, TypeScript will resolve their imports against dependencies’ package.json exports
, but the conditions it uses in the lookup may be wrong: bundlers always set the import
condition for imports and the require
definition for require calls, but TypeScript believes that import declarations in files that have not been explicitly scoped as ESM will be transpiled into require
calls, so it looks up these imports with the require
condition, which could lead to incorrect resolutions. Moreover, in these files, TypeScript will prohibit imports (because it think they are actually require
s) of ESM-format files. The bundler has no such restriction, as its CJS/ESM divide is purely syntactic. (This is a slight oversimplification and the three bundlers mentioned behave slightly differently, but the simplification is good enough for describing the user experience.) If the user tries to get around this by scoping all of their files as ESM by setting "type": "module"
in their own package.json, TypeScript will impose Node’s much stricter ESM resolution algorithm on those files, disabling index-file resolution and extensionless lookups—in fact, the extension the user has to write is .js
, which will be nonsensical for the context, where the runtime module resolver (the bundler) only ever sees .ts
files. (Vite and esbuild tolerate this extension mismatch out of the box; Webpack has historically required a plugin but just added a config setting for it.) This configuration satisfies both the bundler and TypeScript, but at a high DX cost for the user—TypeScript imposes rules on resolution that are wholly unnecessary for the bundler.
Running in Bun
The situation is exactly the same as the above, since Bun’s module resolver is a port of esbuild’s, and it consumes TS files directly.
Bundling with Parcel or Browserify
These bundlers do not (yet) support package.json exports
, so --moduleResolution node
is still a reasonably good fit.
Writing ESM for the browser
Every module resolution mode except classic
performs node_modules resolution, which does not happen in the browser. classic
performs index-file and extensionless lookups, which does not happen in the browser. The closest the user can get is probably to use node16
such that index-file and extensionless lookups are disabled, but they have to take care to avoid node_modules lookups and importing CommonJS dependencies.
Writing ESM for Node, browser, or Deno
We have heard a few arguments recently about the ability to write code targeting multiple runtimes, mostly in the form of “if you let me write my imports with .ts
extensions and emit them as .js
extensions, my input files will work in Deno and my output files will work in Node” which is not generally true. However, it is true that Node, the browser, and Deno have a small amount of overlap in resolution behavior such that it is possible to write ES modules in JS and publish them both to npm and to a CDN where they can be consumed by browsers or Deno. A user trying to do this today faces the same situation as the case above, since the overlap between these systems is just relative URL imports including extensions: there is no mode restrictive enough to avoid writing imports that will work in Node but not in Deno or the browser. (Note that targeting a single bundler which produces a separate output for each target runtime is, for now, a better approach for multi-platform JS authoring.)
Proposal
Existing module resolution modes (with the exception of classic
, whose existence is still a mystery to me) have intended to target one specific runtime and have been named for that runtime—node
(v11 and before), node16
, and nodenext
—and the resolution features they entail are non-configurable implementation details. To move forward, I suggest a strategy of composition: expose some lower-level modes that can be combined with additional options to build up modes that are suitable for a variety of runtime resolvers. If the ecosystem converges on combinations of these settings, we can encapsulate them in a named mode. To start this process, I propose the following reorganization and expansion of options (all names subject to bikeshedding):
Module Resolution Modes
- Expose what is now called
node
as a low-level mode calledconventional
, with a slight modification necessary to support.ts
extension resolution undernoEmit
, and defaultingesModuleInterop
to true. (The name, which I am more than happy to change, is a reference to the fact that most runtimes and bundlers have copied node_modules resolution, extensionless lookups, and special index-file handling from Node to the point where users no longer think of these features as specific to Node. Since we now havenode16
which is highly Node-specific, the goal is to create a situation where the only people who should choose amoduleResolution
option named after Node are people who are actually using Node.) - Implement today’s
node
as a composition ofconventional
and an internal-only option that undoes the modifications mentioned in (1) to preserve backward compatibility. Also, deprecate the namenode
in favor ofnode-legacy
to encourage users of modern Node to consider migrating tonode16
, and to encourage users of other runtimes and bundlers to consider migrating toconventional
. Today’snode
is only accurate to Node v11 and earlier, so we need to start guiding people away from it at some point. - Implement a new low-level mode called
minimal
which resolves only relative module specifiers including file extensions, and attempts to parse all files as ESM. This can be used as a base for browser module resolution. - Leave
classic
,node16
, andnodenext
as they are.
Module Resolution Options
- Add an option to enable/disable package.json
exports
inconventional
and add conditions to the resolver. (May also apply to other modes that do node_modules resolution, i.e. everything butclassic
andminimal
.)
That’s it for now—in the future, import maps, HTTP imports, and other features adopted by more than one runtime resolver should be exposed as options. When/if we have support for import maps and HTTP imports specifically, we should consider creating a mode named browser
that is a composition of minimal
and those options defaulted to true.
Resolution of relative module specifiers ending in .ts
Both minimal
and conventional
(but not node-legacy
) will support resolution to .ts
files by specifying a .ts
extension in the module specifier. This will be an error, as it is today, unless noEmit
is enabled. This allows users who are bundling or directly running their TypeScript source to write relative module specifiers with the extension that their runtime module resolver will actually see, which has always been the underlying goal of telling users to write .js
extensions when their runtime resolver will operate on the emitted JS code. Additionally, in these modes, I suggest that it be legal to write an import type
of a module specifier ending in .d.ts
.
This cannot be supported in today’s node
or node16
in a fully backward-compatible way. In these modes, an import of "./foo.ts"
will resolve to foo.ts.js
or foo.ts.d.ts
in the same directory even if foo.ts
is also present; unsupported extensions are not probed for existence before moving on to fallbacks. This amounts to a bug in node
and node16
. I have proposed to leave the bug in place for node-legacy
to preserve backward compatibility, but it may be reasonable to try fixing it everywhere and listen for feedback.
It should be noted that composite
projects may not disable emit if they are referenced by another project. It may be possible to relax the noEmit
restriction to emitDeclarationOnly
. The primary challenge here is a portability concern: older moduleResolution
modes will not be able to resolve the .ts
-suffixed specifiers in those declaration files. I think it’s worth fixing node16
to support this; they would receive the bug fix described above so that they can always resolve .ts
-suffixed specifiers, but would continue to issue a checker error. That way, we can safely silence the error in declaration files for better portability, and projects that need to use .ts
-suffixed imports could be composite
project references. Fixing node16
in this way would also prepare us for the possibility of Node running directly on TS files, transpiling in-memory like ts-node, an idea that has been gaining traction with Node maintainers recently.
However, I think much of the demand we’ve heard so far for being able to use .ts
-suffixed module specifiers has been misplaced. Users who tried to use node16
with a bundler may have been prompted to add a .js
extension to a module specifier and thought that adding a .ts
extension makes more sense, when in actuality they can continue to use extensionless imports in a mode like conventional
. Others demand .ts
-suffixed imports in combination with module specifier rewriting because they believe that will let them write input code that will run natively in Deno, while tsc’s output will run natively in Node. This is out of scope; the way to write once and ship to multiple environments is to target a bundler and produce multiple bundles. Consequently, I think there are very few users who need to write .ts
-suffixed imports (especially in a world with conventional
), but they are unobjectionable in noEmit
and easy to implement. The feature is not core to this proposal, but I believe it would be a mistake to create new module resolution modes without at least fixing the aforementioned bug to carve out the possibility of .ts
-suffixed imports resolving in the future.
Notes on conventional
This proposal does not allow for a perfect mapping of TypeScript’s resolution behavior onto every bundler, but I think it covers most cases, or what I will call all reasonable cases. If we wanted to be a bit prescriptive, I would be tempted to prohibit .cts
and .cjs
files, disallow import m = require(...)
syntax in TypeScript files, and disable resolution of require
calls in JS files. Some of the newer bundlers are explicitly ESM-only, ignoring or prohibiting require
calls in user code and converting library dependencies from CJS to ESM. No bundler I tested had separate CJS and ESM resolution algorithms, with the exception of setting import
vs. require
in the resolver conditions when looking up package.json exports
. There seems to be little reason to allow explicitly CJS constructs in implementation files in this mode (while CJS constructs in dependency declaration files obviously need to be consumable). As in TS files today, users will still be free to write require
calls, but they will not have special resolution behavior.
Unanswered questions
- This proposal exposes users to increased complexity. I don’t see a way around that. I have also proposed deprecating
--moduleResolution node
, which is the default for--module commonjs
. Consequently, many users are usingnode
without realizing it. This raises the question of what defaults we should have in the future, whatmodule
settings should be allowed with thesemoduleResolution
modes, and more broadly, how to guide users into selecting the correct settings for their project.- A possible mitigation is to allow multiple tsconfig.json
extends
and encourage bundlers to publish (or publish ourselves under@typescript
) tsconfig bases that reflect the resolution behaviors supported by these bundlers out-of-the-box. That way, an esbuild user could write
- A possible mitigation is to allow multiple tsconfig.json
- Should
.ts
-suffixed resolution be automatically allowed based onnoEmit
, or should it be gated behind another flag, or should the capability be preserved for the future but not enabled yet? It makes sense to me thatnoEmit
should enable it, because if you’re writing modules but not emitting, it stands to reason that another tool is going to consume the TS modules that you wrote. @DanielRosenwasser raised the idea that this may cannibalize project references usage, which requires declarations to be emitted and can help speed up type checking when splitting large codebases. More thought needs to be put into how.ts
imports would work with declaration emit. - For simplicity and consistency, can we fix the resolution bug where files with unsupported extensions do not stop other lower priority extensions from being looked up even in today’s
node
, breaking backward compatibility? The first time I considered this, I thought this would be an untenable breaking change, because people rely on this behavior to write declarations for files with unsupported extensions, e.g.import styles from "./styles.css"
would resolve tostyles.css.d.ts
because it thinks that’s analogous tostyles.css.js
, notstyles.css
. However, @weswigham has proposed a general solution for this at Proposal: Enable declaration files for non-js-extensioned files #50133. Taking some form of that proposal may be key to fixing this “bug” in any resolution mode without effectively losing a feature.
Related: #37582, #49083, #46452, #46334, and probably a dozen others