Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] Missing file extension in import statements in transpiled files #1505

Closed
idlist opened this issue Aug 6, 2021 · 12 comments
Closed

Comments

@idlist
Copy link

idlist commented Aug 6, 2021

This question is a continuation of #622.

What I'm trying to do is to transpile .ts file to .js using esbuild as a transpiler and use esm modules instead of cjs in Node.js 16. However, I don't want to bundle the code together (for now put aside whether it is a good decision), and then I encountered the same problem.

Assume the file is add.ts

import { add } from './index'
console.log(add(1, 2))

The configuration is like:

{
  entryPoints: ['./add.ts', './index.ts']
  bundle: false,
  platform: 'node',
  target: ['node16'],
  format: 'esm',
  outExtension: { '.js': '.mjs' }
}

After the transpilation, the result would be add.mjs (and index.mjs which has an export for add())

import { add } from "./index";
console.log(add(1,2));

The problem is, when running the transpiled code, it would throw the error that ./index js not found when importing files. This is because when Node.js is using esm modules, it would not resolve the extension for the import statements. That is, Node.js would only search for the file index (but not index.mjs, or index.js with "type": "module") for this statement:

import { add } from './index'

So, is there any method to rewrite the exported import statement (like the above one) to following import statement that works with Node.js?

import { add } from './index.mjs'

Thanks.

@evanw
Copy link
Owner

evanw commented Aug 6, 2021

So, is there any method to rewrite the exported import statement (like the above one) to following import statement that works with Node.js?

Yes. It’s already in the issue you linked to: #622 (comment). Note that neither TS nor node adds implicit .mjs extensions so by doing this you are writing non-portable code and you may be causing more trouble for yourself than if you did things the way TS and node intend for you to do this: #622 (comment).

@idlist
Copy link
Author

idlist commented Aug 6, 2021

Then I guess I'm going to stick to (probably preferable) cjs export then... 🤔

I checked the comment again and maybe I ignored this at the first time

So to import ./index.js you would just write import {add} from './index.js' in your TypeScript code and the TypeScript type checker will import from ./index.ts instead of from ./index.js.

The plugin solution on the other hand is a bit weird in my opinion, since using esm output often means "I don't want to bundle the codes" (at least in my case), but that solution requires bundle: true. Actually, this problem would mostly (maybe only) appear when bundle: false, since only in this case do I need to read local modules (which causes this problem), and the import statement with node_modules modules are totally fine. If bundle: true is set, the file is transpiled to a single file and there is no need to read local modules and thus mustn't cause this problem 🤔

Thanks for your comment! 👍

@evanw
Copy link
Owner

evanw commented Aug 6, 2021

Setting bundle: true basically just means “process import paths.” What happens to those paths depends on whether they have been marked as external or not. If they are all external then they won’t end up in the bundle. All bundling gives you in that case is just the ability to change the import paths.

@idlist
Copy link
Author

idlist commented Aug 6, 2021

I see... Thanks for your explanation.

These are the files that I tried to use esm output. They are server-side codes that written in TypeScript
image

And I tried something like this:

  build({
    entryPoints: glob.sync('./backend/src/**/*.ts'),
    outdir: './backend/dist',
    bundle: true,
    platform: 'node',
    target: ['node16'],
    format: 'esm',
    outExtension: { '.js': '.mjs' },
    plugins: [
      nodeExternalsPlugin(),
      {
        name: 'add-mjs',
        setup(build) {
          build.onResolve({ filter: /.*/ }, args => {
            if (args.importer)
              return { path: args.path + '.mjs', external: true }
          })
        },
      }
    ]
  })

And then new problem appears. The Node.js library modules are also renamed (becomes something like fs.mjs)... I didn't test yet but I guess it is because I didn't set Node.js libraries as external (setting external: ['node'] didn't work), but if it's true then manually setting Node.js libraries as externals would be simply annoying.

Guess I'm closing this issue later just because I would finally stick to cjs outputs... 🤣

@hyrious
Copy link

hyrious commented Aug 7, 2021

@idlist Try splitting when you're bundling ESM files to multiple entries. For example you can see my repo.

@idlist idlist closed this as completed Aug 8, 2021
@idlist
Copy link
Author

idlist commented Oct 5, 2021

For someone who happened to read across this issue:

There is a way to make the import statement work under "type": "module":

import { add } from './index.js' // instead of barely './index'
                                 // although it is written in TS file, it would work correctly.
                                 // "import { add } from 'index.mjs'" may also work, but I didn't try
console.log(add(1, 2))

As #622 said, it's the design of TypeScript that retains the relative import as is, and this is the main point behind this snippet. But neither TypeScript nor esbuild gave an example on the "type": "module"` scenario, so... that's it! 🤣

@evanw
Copy link
Owner

evanw commented Oct 5, 2021

For those following along: TypeScript 4.5 is finally shipping a solution to this problem: two new file extensions, .mts and .cts. You can read TypeScript's 4.5 beta announcement for details but basically you will be able to import {x} from "./foo.mjs" and then write code in foo.mts (not foo.mjs) and everything should work. TypeScript will type check the import of foo.mjs using types from foo.mts, TypeScript will generate foo.mjs from foo.mts when compiling, and esbuild will (in the next release, which should be out before the TypeScript 4.5 beta is over) bundle the file foo.mts for the import of foo.mjs. A similar thing will work for .cjs and .cts. And you can of course still use "type": "module" with .js and .ts as you have always been able to.

@lukeed
Copy link
Contributor

lukeed commented Oct 5, 2021

Will this be overridable thru --out-extension?

IIRC, some of the reasoning for adding these file types was to better follow/analyze exports in a file. Right now, it's a bit rough to have a project that intermixes mjs and cjs files (given the single "module" config), so separating these out within TS's domain allows tsc to safely and seamlessly swap formats.

In this new world, you could still be working with .ts, .cts, and .mts files and only want to produce .js outputs. Perhaps a legacy iife bundle.

@evanw
Copy link
Owner

evanw commented Oct 5, 2021

This new behavior I was describing currently only affects the inputs to esbuild, not the outputs. So what you're saying would work fine (esbuild would still only ever generate .js files by default).

@lukeed
Copy link
Contributor

lukeed commented Oct 5, 2021

That's great, thanks for the clarification :)

@lukeed
Copy link
Contributor

lukeed commented Oct 5, 2021

Hey @evanw, maybe you already have this covered, but it sounds like one should be able to write import { foo } from './foo.js' irrespective of a "type": "module" setting.

// main.ts
import { foo } from './foo.js';
// => only "foo.ts" exists

The output main.js (or main.mjs, doesn't matter) would retain the foo.js import, leaving it up to the user's distribution layer to determine whether or not .js containing ESM is valid.

So, to me, Orta's thread (and the conclusion of microsoft/TypeScript#16577) is saying that when a .ts file imports:

  • thing.js – looks for thing.js then thing.ts
  • thing.mjs – looks for a thing.mjs then thing.mts
  • thing.cjs – looks for a thing.cjs then thing.cts

And this is all because .ts produces .js, mts -> mjs, cts -> cjs.


Relatedly, this could be more fodder for #1652 😄

@nicomouss
Copy link

nicomouss commented Apr 23, 2022

What I'm trying to do is to transpile .ts file to .js using esbuild as a transpiler and use esm modules instead of cjs in Node.js 16. However, I don't want to bundle the code together (for now put aside whether it is a good decision)

Here is a fully working workflow to properly transpile .ts files with esbuild, into .js files carrying esm modules, then run everything with nodejs. That way you keep everything clean from start to finish.

// yourProject/src/add.ts
export function add(a: number, b: number) {
	return a + b;
}
// yourProject/src/index.ts
import { add } from "./add";

console.log(add(2, 2));

Your esbuild config:

// somewhere inside yourProject/build.js
{
  entryPoints: ['./src/index.ts','./src/add.ts'],
  outdir: './dist'
  bundle: false,
  platform: 'node',
  target: 'node16',
  format: 'esm'
}

You run a build and you get those files generated in your dist folder:

// yourProject/dist/add.js
function add(a, b) {
  return a + b;
}
export {
  add
};
// yourProject/dist/index.js
import { add } from "./add";
console.log(add(2, 2));

Now, if you try to run this generated code with nodejs:

node ./dist/index.js

You get the node.js error regarding imports (meaning nodejs is screaming that it does not understand this import keyword inside this .jsfile):

import { add } from "./add";
^^^^^^
SyntaxError: Cannot use import statement outside a module
    at wrapSafe (internal/modules/cjs/loader.js:979:16)
    at Module._compile (internal/modules/cjs/loader.js:1027:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
    at internal/main/run_main_module.js:17:47

At this point, instead of jumping into the .mjs renaming stuff to fix that (which leads to messing up your imports in the first place in your .ts files), just properly configure your package.json file:

{
  "name": "yourProject",
  "type": "module"
}

Now, if you run your program again, nodejs is now able to understand the imports, and you don't get the previous error anymore. Although you get a new error:

internal/process/esm_loader.js:74
    internalBinding('errors').triggerUncaughtException(
                              ^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'yourProject/dist/add' imported from yourProject/dist/index.js
    at finalizeResolution (internal/modules/esm/resolve.js:276:11)
    at moduleResolve (internal/modules/esm/resolve.js:699:10)
    at Loader.defaultResolve [as _resolve] (internal/modules/esm/resolve.js:810:11)
    at Loader.resolve (internal/modules/esm/loader.js:85:40)
    at Loader.getModuleJob (internal/modules/esm/loader.js:229:28)
    at ModuleWrap.<anonymous> (internal/modules/esm/module_job.js:51:40)
    at link (internal/modules/esm/module_job.js:50:36) {
  code: 'ERR_MODULE_NOT_FOUND'
}

This is because the module loader used by nodejs requires the full path to a module to be provided to the loader (so it needs the file extension as well). Instead of adding extensions to all your imports (which leads to messing up your imports with .js extensions in your .ts files again), just run your program like so (see documentation here):

node --experimental-specifier-resolution=node ./dist/main.js

Now everything works fine. With this workflow, your .ts source files stays clean, everything is generated into .js files (no .mjs stuff...), and nodejs is launched with the proper options to get what we want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants