import-js / eslint-plugin-import

ESLint plugin with rules that help validate proper imports.
MIT License
5.51k stars 1.56k forks source link

when using typescript recommended config it should allow for imports of js equivialent #2111

Open lifeiscontent opened 3 years ago

lifeiscontent commented 3 years ago

based off the conversation here: https://github.com/microsoft/TypeScript/issues/16577

if a .ts or .tsx file imports a .js file it will resolve .ts .tsx or .js but the plugin:import/typescript doesn't seem to have support for this functionality.

the js extension is used to reference files that WILL be compiled to, so they technically don't exist, but this is how the TS team is allowing people to write ESM compatible modules in TypeScript.

It would be nice to have this eslint plugin to work with this out of the box so I can enforce that all of my modules import .js files to make sure I'm not missing anything when exporting my package to the web.

ljharb commented 3 years ago

The "recommended" config of course doesn't support any typescript functionality - the default is (should always be) JavaScript.

https://github.com/benmosher/eslint-plugin-import#typescript may be helpful.

lifeiscontent commented 3 years ago

@ljharb Yeah, I'm specifically talking about the typescript additions.

leepowelldev commented 3 years ago

@lifeiscontent - try using: https://www.npmjs.com/package/eslint-import-resolver-typescript

leepowelldev commented 3 years ago

@ljharb - would there be any appetite for import/extensions rule to support a typescript option which would allow .ts and .tsx to be equivalent to a .js extension which the Typescript compiler allows us to use as mentioned by @lifeiscontent

// foo.ts
export const foo = 'foo';

// bar.ts
import { foo } from './foo.js';

Currently this errors with Missing file extension "ts" for "./foo.js". Maybe we could add an additional option? I'm happy to take a stab at it if it's something that was welcomed?

ljharb commented 3 years ago

I don’t think that would really make sense. It would be modifying a general rule with typescript-specific logic to paper over a typescript bug/flaw - one that once they fix, we’d be stuck supporting the workaround for forever.

leepowelldev commented 3 years ago

@ljharb - sorry, do you mind explaining what the Typescript bug/flaw is? Currently I'm struggling to be able to enforce .js extensions in a project using Typescript using this rule (which will go on to be published as a ESM package)

ljharb commented 3 years ago

@leepowelldev my understanding is that typescript explicitly forces you to omit extensions, so that it can conceptually point to .ts at dev time but .js at runtime. TS and native ESM are effectively not yet compatible, and can't be used together.

leepowelldev commented 3 years ago

@ljharb Typescript doesn't explicitly force you to omit extensions. It won't let you use .ts ot .tsx as an extension, but .js is perfectly valid (https://github.com/microsoft/TypeScript/issues/16577). This allows me to create valid ESM output from a Typescript source.

TS and native ESM are effectively not yet compatible, and can't be used together.

I'm not sure what you mean by this.

ljharb commented 3 years ago

I mean that if your goal is a native ESM package (something i'd strongly discourage for many reasons, unrelated to this), then authoring in TypeScript is not yet an ideal way to get there, because of node's unfortunate "required extensions for ESM" choice.

What I'd suggest is to only use tsc as a typechecker, to use babel as your only transpiler, and then to use a babel plugin to transform the proper omitted-extensions source to whatever output you like, which can include "native ESM with extensions".

patrickarlt commented 3 years ago

I'm migrating a large project written in TypeScript to output native ESM modules for Node. This requires all the import statements in TypeScript to end in .js so they get output with the .js extensions and I would like to validate that behavior.

I don't think TypeScript is going to budge on their treatment of extensions, see https://github.com/microsoft/TypeScript/issues/16577#issuecomment-754941937, neither will Node JS back off of required extensions for ESM so it is perfectly valid to write this:

// foo.ts
export const foo = 'foo';

// bar.ts
import { foo } from './foo.js';

Currently this config produces Missing file extension "ts" for "./foo.js" which is illogical since TypeScript doesn't actually support .ts on file paths https://github.com/microsoft/TypeScript/issues/37582.

module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 2018,
    sourceType: "module"
  },
  plugins: ["@typescript-eslint", "import"],
  extends: ["plugin:import/recommended", "plugin:import/typescript"],
  rules: {
    "import/extensions": ["error", "ignorePackages"]
  },
  settings: {
    "import/extensions": [".js", ".jsx"],
    "import/parsers": {
      "@typescript-eslint/parser": [".ts", ".tsx"]
    },
    "import/resolver": {
      typescript: {
        project: "./"
      }
    }
  }
};
patrickarlt commented 3 years ago

Would a compromise maybe be to introduce a validExentions option so we could say that .js is a valid extension in .ts files?

patrickarlt commented 3 years ago

There is also this issue ineslint-import-resolver-typescript https://github.com/alexgorbatchev/eslint-import-resolver-typescript/issues/80 that mentions this exact use case.

ljharb commented 3 years ago

I think the proper place to do that is the TS resolver. It can, for example, resolve otherwise-missing .js files to their present .ts equivalent.

leepowelldev commented 3 years ago

Looking at the src for extensions rule I believe this plugin would still error even if anything was done in the resolver.

I have to disagree that supporting this feature should be done at the resolver level.

ljharb commented 3 years ago

That suggests to me that the extensions rule needs refactoring, if it’s hardcoding node resolution.

leepowelldev commented 3 years ago

I'm not sure about that ... I think it resolves just fine (would need to check), but then simply check that the file extensions match. Obviously it fails as expected when it tries to match .js with .ts or .tsx. We would need additional checks to allow this to be valid.

patrickarlt commented 3 years ago

I just opened https://github.com/alexgorbatchev/eslint-import-resolver-typescript/issues/82 so we can see what might be able to be done in the resolver. Although I do agree with @leepowelldev that it makes more sense to handle this behavior in the rules not the resolver.

This behavior is really specific to TypeScript though so would this be best as a separate rule just for TypeScript so it doesn't conflict?

ljharb commented 3 years ago

We don’t have any typescript-specific rules; this is a JavaScript plugin that works for TS as well.

leepowelldev commented 3 years ago

Sure, and I don't think this has to be typescript specific if we can expose a config option to support alternative relationships between extensions.

"import/extensions": [
  <severity>,
  "never" | "always" | "ignorePackages",
  {
    ignorePackages: true | false,
    pattern: {
      <extension>: "never" | "always" | "ignorePackages"
    },
    mapExtensions: {
      "js": "tsx?"
    }
  }
]

This gives the flexibility without directly supporting Typescript.

JounQin commented 3 years ago

This is actually already supported at https://github.com/alexgorbatchev/eslint-import-resolver-typescript/pull/56.

Just remove "import/extensions" this setting.

This resolver resolves the .ts file correctly, but it's not allowed in your configuration.

@ljharb The problem is there is no way to enforce to use .js extension currently for .ts files.

ljharb commented 3 years ago

and overrides that only apply to .ts files, that set the extension to be “always”, presumably would force .ts, and you want .js?

JounQin commented 3 years ago

and overrides that only apply to .ts files, that set the extension to be “always”, presumably would force .ts, and you want .js?

Yep.

patrickarlt commented 3 years ago

Thanks to @JounQin I was able to get this working by excluding eslint-import-resolver-typescript which seems a but counter initiative but it is working. If anyone wants to take a look at the minimal reproduction I made I can push it up to GitHub.

// src/bar.ts - should pass
import { foo } from "./foo.js";

// src/baz.ts - should fail
import { foo } from "./foo";

// src/foo.ts
export const foo = "foo";

With eslint-import-resolver-typescript - both bar.ts and baz.ts fail

2021-09-09_12-22-27

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": [
    "import"
  ],
  "rules": {
    "import/extensions": ["error", "ignorePackages"]
  },
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [
        ".ts",
        ".tsx"
      ]
    },
    "import/resolver": {
      "typescript": {
        "project": "./tsconfig.json"
      }
    }
  }
}

Without eslint-import-resolver-typescript - works as intended

2021-09-09_12-22-14

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": [
    "import"
  ],
  "rules": {
    "import/extensions": ["error", "ignorePackages"]
  },
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [
        ".ts",
        ".tsx"
      ]
    }
  }
}
leepowelldev commented 3 years ago

@patrickarlt so if we just don't use eslint-import-resolver-typescript then this works?

patrickarlt commented 3 years ago

@leepowelldev It works unless you also enable the import/no-unresolved rule. In my quest to make a VERY minimal reproduction I didn't extend plugin:import/recommended and plugin:import/typescript. So if you don't use eslint-import-resolver-typescript and keep import/no-unresolved disabled it works which I don't really think is intended. I think this is what was discussed in https://github.com/import-js/eslint-plugin-import/issues/2111#issuecomment-916212917. The resolver works but the import/extensions rule doesn't think the extension is correct.

My minimal reproduction of this is at https://github.com/patrickarlt/minimal-typescript-eslint-with-extensions.

Adding import/no-unresolved without eslint-import-resolver-typescript breaks resolving (predictable)

2021-09-09_12-50-07

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": [
    "import"
  ],
  "extends": [
    "plugin:import/recommended",
    "plugin:import/typescript"
  ],
  "rules": {
    "import/extensions": ["error", "ignorePackages"]
  },
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [
        ".ts",
        ".tsx"
      ]
    }
  }
}

Adding import/no-unresolved + eslint-import-resolver-typescript breaks (incorrect ./foo.js is valid TypeScript)

2021-09-09_12-53-07

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": [
    "import"
  ],
  "extends": [
    "plugin:import/recommended",
    "plugin:import/typescript"
  ],
  "rules": {
    "import/extensions": ["error", "ignorePackages"]
  },
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [
        ".ts",
        ".tsx"
      ]
    },
    "import/resolver": {
      "typescript": {
      }
    }
  }
}
ljharb commented 3 years ago

Unfortunately no-unresolved is necessary to detect missing untyped packages, but perhaps adding an option to no-unresolved might be the solution here?

patrickarlt commented 3 years ago

@ljharb I think the behavior everyone is asking for is this:

Using this config:

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": [
    "import"
  ],
  "extends": [
    "plugin:import/recommended",
    "plugin:import/typescript"
  ],
  "rules": {
    "import/extensions": ["error", "ignorePackages"]
  },
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [
        ".ts",
        ".tsx"
      ]
    },
    "import/resolver": {
      "typescript": {
      }
    }
  }
}

And these files

// src/bar.ts - should pass
import { foo } from "./foo.js";

// src/baz.ts - should fail
import { foo } from "./foo";

// src/foo.ts
export const foo = "foo";

The expected output should be:

Currently it is:

This is because import { foo } from "./foo.js"; is valid TypeScript that compiles to import { foo } from "./foo.js"; If you want the extensions to output from the TypeScript compiler you must add them in the source.

It looks like eslint-import-resolver-typescript resolves to the correct file but imports/extensions rejects that you can import .js from a .ts file.

JounQin commented 3 years ago

@patrickarlt Try to add ts: never, tsx: never in the rule configuration like the PR's example.

patrickarlt commented 3 years ago

@JounQin unfortunately that just seem to make everything pass.

2021-09-09_20-17-56

{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": [
    "import"
  ],
  "extends": [
    "plugin:import/recommended",
    "plugin:import/typescript"
  ],
  "rules": {
    "import/extensions": [
      "error",
      "always",
      {
        "ts": "never",
        "tsx": "never"
      }
    ] 
  },
  "settings": {
    "import/parsers": {
      "@typescript-eslint/parser": [
        ".ts",
        ".tsx"
      ]
    },
    "import/resolver": {
      "typescript": {
      }
    }
  }
}
JounQin commented 3 years ago

@patrickarlt

I've already said previously.

@ljharb The problem is there is no way to enforce to use .js extension currently for .ts files.

beamery-tomht commented 3 years ago

Any movement on this? It would be great if we can get the extension linting working correctly for ESM in TS as well as the no-unresolved.

I'm experiencing exactly the same issues as described by @patrickarlt. I need the resolver for no-unresolved to work, but as soon as I enable the resolver, the extensions check gives me false negatives. Disabling the no-unresolved is not really a solution and the extensions check would add a lot of value!

ljharb commented 3 years ago

TS recently merged a PR which might address this problem in TS itself.

beamery-tomht commented 3 years ago

@ljharb can you elaborate on that please? which PR and how does it solve the problem? The request here is for a linting rule, we already have the ability to import TS files using a .js extension for ESM build outputs. The linting rule would ensure that the extension isn't forgotten

ljharb commented 3 years ago

I mean https://github.com/microsoft/TypeScript/pull/45884 - which I believe will allow TS to do the right thing here, so that your imports in code don't have to have extensions (since source code shouldn't be using them) and they'll still transpile/resolve to the proper extensioned path.

In other words, a solution here would be a hack around a TypeScript flaw; TS seems on track to fix that flaw, obviating the need for any workaround.

beamery-tomht commented 3 years ago

From my understanding, the extension is mandatory in both Node and the Browser https://nodejs.org/api/esm.html#esm_mandatory_file_extensions

It's only bundling systems like babel/webpack that have added the feature of omitting the extension.

TS is not automating this extension as it's an assumption of the extension. TS won't modify import statements at compile as it risks changing the behaviour of the code.

Consider the following:

// file.ts
import x from './x';

Given

somedir/
  file.ts
  x.mjs
  x.d.ts
  x.ts
  x.tsx

So I wouldn't call it a "hack". I don't see them changing this behaviour.

It would make everyone happy to be able to lint against this extension being present in relative imports.

ljharb commented 3 years ago

Your understanding is incorrect; the browser has no concept of extensions whatsoever, and the extension in node is only mandatory for relative imports - imports from packages should omit them by setting up extensionless entry points in their “exports” field.

beamery-tomht commented 3 years ago

Perhaps I misunderstood this statement about mandatory extensions in native modules. I took "native" here to mean native in the browser.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#:~:text=Note%3A%20In%20some%20module%20systems%2C%20you%20can%20omit%20the%20file%20extension%20and%20the%20leading%20/%2C%20./%2C%20or%20../%20(e.g.%20%27modules/square%27).%20This%20doesn%27t%20work%20in%20native%20JavaScript%20modules.

It states clearly that the server would need to support the mime type, but the browser will infer the content type from the extension.

ljharb commented 3 years ago

That is explicitly saying that browsers provide no "lookup" - browsers in NO WAY have the concept of extensions, and the MIME type is only and always explicitly defined by the server and/or by the HTML tag that loaded it.

leepowelldev commented 3 years ago

Not quite sure where this conversation is going. Personally I don't believe the upcoming TS release will address this issue. @ljharb obviously advocates using extensionless imports, while a number of others want to use them (for whatever personal reasons they may have).

tpluscode commented 3 years ago

@ljharb obviously advocates using extensionless imports

Wait, what? Extensionless imports only work for bare module and explicit declarations in package.json exports field. Browser argument is rather a little beside the point. Yes, browser does not care for extension per-se but if the module is /foo/bar.js then it cannot be imported as /foo/bar.

So unless TS has the option to output explicit extensions in imported paths we are forced to make sure all our TS modules are imported as *.js

leepowelldev commented 3 years ago

@tpluscode - I completely agree, but I think @ljharb made the argument that the .js extension should be added as part of the build process, either by a babel plugin or as subsequent step to the typescript compiler.

tpluscode commented 3 years ago

That's not so simple. An import from './foo/bar' can mean both ./foo/bar.js as well as ./foo/bar/index.js and both can exist on disk. Unless the import module is explicit, trying to defer "fixing" paths to the build is bound to be error prone. This is exactly where a import linter seems to fit right in

ljharb commented 3 years ago

This plugin should certainly disambiguate those possibilities, but that actually happens very rarely in practice.

beamery-tomht commented 3 years ago

TypeScript will not modify the import extension at compile time. They have made this clear in multiple (very long) threads.

https://github.com/microsoft/TypeScript/issues/16577#issuecomment-754941937

ljharb commented 3 years ago

@beamery-tomht they already do that with .ts to .js on the file; they just don't want to modify your source. So, if your source remains extensionless, then https://github.com/microsoft/TypeScript/pull/45774 (merged in https://github.com/microsoft/TypeScript/pull/45884) i believe should work, unless i'm misunderstanding something.

beamery-tomht commented 3 years ago

They aren't modifying the extension at compile time. They are supporting the use of extensions in your source code. When the source is extensionless, the dist is also extensionless, which is invalid ESM.

https://www.staging-typescript.org/play?target=7&module=199&ts=4.5.0-beta#code/JYWwDg9gTgLgBAbzgZwiApjAFsAdgczgF84AzKNOAcgDoB6VDAdy3SnSoChOBjCXVABt0NQRHwAKRphwEAlEA

https://www.staging-typescript.org/play?esModuleInterop=false&target=7&module=7&allowUmdGlobalAccess=false&ts=4.5.0-beta#code/JYWwDg9gTgLgBAbzgZwiApjAFsAdgczgF84AzKNOAcgDoB6VDAdy3SnSoChOBjCXVABt0NQRHwAKRphwEAlEA

beamery-tomht commented 3 years ago

That is explicitly saying that browsers provide no "lookup"

It's explicitly stating that the extension cannot be omitted in browsers for ESM.

ljharb commented 3 years ago

No, it is not. It's saying that the browser will ask the webserver for the exact URL in the source code, whether it has an extension or not, and that it's up to the webserver to handle it. It's exceedingly easy - and in fact common - for webservers to do extension lookup, and certainly to do index lookup.

Browsers do not have any idea of what an extension is - the word is meaningless in a browser context.

leepowelldev commented 3 years ago

they already do that with .ts to .js on the file; they just don't want to modify your source. So, if your source remains extensionless, then microsoft/TypeScript#45774 (merged in microsoft/TypeScript#45884) i believe should work, unless i'm misunderstanding something.

This is part of the problem - TS doesn’t allow the use of .ts and .tsx extensions in source code. So you either have to be extensionless or use .js

ljharb commented 3 years ago

@leepowelldev ok so, to be clear, the problem (assuming the latest TS changes I referenced) is:

  1. you omit extensions in your source. TS produces output files with an extension (.js or .mjs or .cjs, i presume?), but the import specifiers remain extensionless. This works fine with this plugin, and every ESM use case except "native ESM in node" or "native ESM in a browser, and without an import map that maps the extensionless specifier to the desired URL", the latter of which is trivial to fix and thus I don't consider remotely important. The former, however, is important to fix - but, this plugin does not support native node ESM, until resolve supports exports (node itself doesn't even provide a non-experimental way to resolve ESM specifiers, so it's just not possible for most tooling to support native ESM yet)
  2. you use .js in your source. TS produces output files with a .js extension. Everything works fine in every ESM use case - you'd still want an import map in the browser - but this plugin will report errors whenever those output files do not exist on disk.

Is that accurate?