microsoft/TypeScript

Can't control which functional `import`s are shimmed under `rewriteRelativeImportExtensions`

Closed this issue ยท 12 comments

Acknowledgement

  • I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

Comment

When using rewriteRelativeImportExtensions, all relative imports with ts extensions are changed to js. This is fine for static imports but, since importing and running ts files is still possible in some environments, this may be problematic or worst, buggy, for functional imports, see this exemple :

// filePath is the path to a file that being listed at runtime and can have a ts extension
if (filePath.endsWith('.ts')) {
	const file = await import(filePath);
	// do something
}

import(filePath) will be unconditionally shimmed here, and it's not a desirable behavior because the shimming process clearly introduces a bug: a ts file will have his extension changed to js, and import() will throw or, even worst, open the js file if it exists (it may be or not the equivalent of the original file).

How to control and/or disable this behavior?

See #59767 for details about rewriteRelativeImportExtensions

uhyo commented

TypeScript compiler options let you adjust the compiler's behavior so that it aligns with the environment you're going to run your code on.

The rewriteRelativeImportExtensions option is suited when your code runs on a platform that requires a .js extension.

If the environment is expected to accept the .ts extension, the option should not be used.

Iโ€™m not sure why you still need the extension rewrite where the environment supports the .ts extension; but if it is truly needed, unfortunately the environment you are dealing with isnโ€™t perfectly supported by TypeScript. ๐Ÿฅฒ

I don't particularly understand the question. If rewriteRelativeImportExtensions doesn't work in your scenario, we recommend not using it. There is not per-import or per-statement control over this flag because there doesn't seem to be a scenario where that makes sense.

The scenario is the target one of rewriteRelativeImportExtensions : writing a code base where some parts can be run in place in node, which requires ts extensions, but that can also be built with tsc and distributed as a library, via npm as an example, and then used as a dependency elsewhere, in other projets. Node forbids importing any ts file under node_modules, so the library needs to be built and its own code source must have extensions rewritten. But this process also shim all the functional imports inside the lib, regardless of what they really import, to rewrite extensions at runtime.
The problem, is it makes completely impossible to import external ts file for the functional imports used in this lib, and worst ever, makes tsc to produce a bugged code from something correct.

A real world scenario would be to create a library that can be configured via a typescript file in the project depending of this lib. Popular libraries using this pattern exist nowadays, often seeking for a project's root xxx.config.ts file.
Ex: vite/vitest, eslint (partially)

The problem is this unconditional auto-shim on functional imports: even when using rewriteRelativeImportExtensions, you can't presume that extensions must be rewritten every time import() is used. There's some use cases where it will be needed and some other where not.

The problem, is it makes completely impossible to import external ts file for the functional imports used in this lib, and worst ever, makes tsc to produce a bugged code from something correct.

What is "external" ?

There's some use cases where it will be needed and some other where not.

Right; this was the primary source of our hesitation about doing this in the first place, and this argument was not persuasive to many people. If RRIE doesn't work for you in your scenario, you'll need to use a different tool for figuring out which imports need rewriting and which don't (in our terminology we call this a bundler)

What is "external" ?

External files are those that are not part of the library code base, located anywhere on a hard drive. If you want a familiar example, create a TS project, add eslint in dependencies and add an eslint.config.ts file. From the point of view of the eslint lib, this config file is "external" to its code base. Currently, eslint internally uses a functional import to load this file. If the eslint lib was built with tsc and rewriteRelativeImportExtensions (for any good reasons), this import would have failed.
(note: currently, a special flag or jiti is needed to make eslint using this ts config file in a node environment, but that's not the purpose here)

I perfectly know the subjects you linked and I share these points of view. I consider the current rewriteRelativeImportExtensions rules of extensions rewriting pretty reasonable too, and they are probably fine to ease our lives for most needs.

In fact, the only "dangerous" problem with rewriteRelativeImportExtensions is this unconditional auto-shim injected in functional imports. This is frustrated, because, even if workarounds are possible, they are workarounds, when a simple solution could exist to locally disable this shimming, without being a breaking change, like :

// filePath is the path to a file that being listed at runtime and can have a ts extension
if (filePath.endsWith('.ts')) {
	// @ts-no-rewriteRelativeImportExtensions don't shim this import, we really want to load the .ts file here
	const file = await import(filePath);
	// do something
}

If importing a file with a .ts extension works in this environment, why is RRIE on the first place? It seems unnecessary?

I think you don't ignore that if the node environment can import a file with a .ts extension, it refuses to do so if the file is located in node_modules. Basically, if you want to create a library that targets node, you can't distribute it with .ts extensions inside, you have to build your lib to only distribute .js and .d.ts files. Anyway, if you target other environments in parallel, it's mandatory to distribute .js files to ensure the best compatibility (it's not a virtual restriction in this case).
And if for some reasons, you have to run code in place within the lib project too, with node again, you have to use these .ts extensions in imports, and set tsc with RRIE to transform them for the built and distributed packet. This is why RRIE is useful. This the "good reason to use" case of RRIE.
Now, if your library packet, built with tsc and RRIE, installed as a dependency in the node_modules of a dependent project, have to functionally import a random .ts files for some reason (a config file for exemple), it will never able to do so: all its internal import() calls have been shimmed to transform any path ending with .ts by .js, with no way for the developer to handle this wrong behavior.

no way for the developer to handle this wrong behavior.

There are a number of other options available, e.g.

async function f() {
  // Shadow outer helper
  function __rewriteRelativeImportExtension(s) {
    return s;
  }
  const s = "foo.ts";
  const p = await import(s)
}

It's one of the possible workarounds that I was speaking about, but it's still a workaround ๐Ÿ˜‰
So I correct my sentence : "no clean and documented way for the developer to handle this wrong behavior."
Another workaround, less tricky, is simply to export a function aliasing import() in a plain JS file, not built by tsc. But one more time, it's a workaround, not a feature.

Hi, I am also concerned.

I am a big user of Vite which uses "vite.config.ts".

I am planning to create a dev framework which may use config files in ts format for its benefits over js and json.

Before I share my thoughts, I want to ask:

async function f() {
  // Shadow outer helper
  function __rewriteRelativeImportExtension(s) {
    return s;
  }
  const s = "foo.ts";
  const p = await import(s)
}

Sorry but I don't understand this.

What is the role of the function __rewriteRelativeImportExtension which is not called anywhere?

How can await import(s) here import the .ts file without shimming?

What is the role of the function __rewriteRelativeImportExtension which is not called anywhere?

It will be called once the file is JS, since the emit of this is

async function f() {
  // Shadow outer helper
  function __rewriteRelativeImportExtension(s) {
    return s;
  }
  const s = "foo.ts";
  const p = await import(__rewriteRelativeImportExtension(s));
}

An explanatory piece of code

Oh. I thought __rewriteRelativeImportExtension is just a random name.

After a little digging in #59767 did I realize this is a hidden global function only used in js output.

OK. Here's a result of my understanding.

/**
 * Bypass the auto shimming by `rewriteRelativeImportExtensions` mechanism which would otherwise replace ".ts" in the `path` of `import(path)` with ".js".
 * @see https://www.typescriptlang.org/vo/tsconfig/#rewriteRelativeImportExtensions
 */
function importJustTs(s) {
	/**
	 * locally overwrite the global function `__rewriteRelativeImportExtension`.
	 */
	var __rewriteRelativeImportExtension = (s)=>s

	/**
	 * This line of TypeScript code will be transpiled into JavaScript as:
	 * 
	 * ```js
	 * import(__rewriteRelativeImportExtension(s));
	 * ```
	 *
	 * See [this pull request](https://github.com/microsoft/TypeScript/pull/59767) for more details about `__rewriteRelativeImportExtension`
	 */
	return import(s)
}

async function f() {
	/*
		You can simply use `importTsOnly()` instead of `import()`
	 */
  const p = await importTsOnly("foo.ts")
}

Suggestion

In TypeScript 5.7 release notes, there are already workarounds for some edge situations.

I think it will be very helpful to add this one, too.

People in need can easily find that page from Google search and happily grab the code.

Which should be default behavior?

I see two conflicting needs:

  • A: Since the rewriting works for import statement, we naturally anticipate it does for import() function as well.
  • B: No, when we use import(), occasionally the rewriting is not wanted.

I wonder how many practices are in Case A and B respectively.

What I can think of a typical use case for A is conditional import for different environments (like OSs), which is quite common in libs.

If there is not a wide gap between the practice number of the two cases, I approve A to be the default.