Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,18 @@ namespace ts {
const builtinGlobals = createSymbolTable();
builtinGlobals.set(undefinedSymbol.escapedName, undefinedSymbol);

// Extensions suggested for path imports when module resolution is node12 or higher.
// The first element of each tuple is the extension a file has.
// The second element of each tuple is the extension that should be used in a path import.
// e.g. if we want to import file `foo.mts`, we should write `import {} from "./foo.mjs".
const suggestedExtensions: [string, string][] = [
Comment thread
gabritto marked this conversation as resolved.
[".mts", ".mjs"],
[".ts", ".js"],
[".cts", ".cjs"],
[".mjs", ".mjs"],
[".js", ".js"],
[".cjs", ".cjs"]];
Comment thread
gabritto marked this conversation as resolved.
Outdated

initializeTypeChecker();

return checker;
Expand Down Expand Up @@ -3417,7 +3429,7 @@ namespace ts {
(isModuleDeclaration(location) ? location : location.parent && isModuleDeclaration(location.parent) && location.parent.name === location ? location.parent : undefined)?.name ||
(isLiteralImportTypeNode(location) ? location : undefined)?.argument.literal;
const mode = contextSpecifier && isStringLiteralLike(contextSpecifier) ? getModeForUsageLocation(currentSourceFile, contextSpecifier) : currentSourceFile.impliedNodeFormat;
const resolvedModule = getResolvedModule(currentSourceFile, moduleReference, mode)!; // TODO: GH#18217
const resolvedModule = getResolvedModule(currentSourceFile, moduleReference, mode);
const resolutionDiagnostic = resolvedModule && getResolutionDiagnostic(compilerOptions, resolvedModule);
const sourceFile = resolvedModule && !resolutionDiagnostic && host.getSourceFile(resolvedModule.resolvedFileName);
if (sourceFile) {
Expand Down Expand Up @@ -3460,10 +3472,10 @@ namespace ts {
if (resolvedModule && !resolutionExtensionIsTSOrJson(resolvedModule.extension) && resolutionDiagnostic === undefined || resolutionDiagnostic === Diagnostics.Could_not_find_a_declaration_file_for_module_0_1_implicitly_has_an_any_type) {
if (isForAugmentation) {
const diag = Diagnostics.Invalid_module_name_in_augmentation_Module_0_resolves_to_an_untyped_module_at_1_which_cannot_be_augmented;
error(errorNode, diag, moduleReference, resolvedModule.resolvedFileName);
error(errorNode, diag, moduleReference, resolvedModule!.resolvedFileName); // TODO: GH#18217
Comment thread
gabritto marked this conversation as resolved.
Outdated
}
else {
errorOnImplicitAnyModule(/*isError*/ noImplicitAny && !!moduleNotFoundError, errorNode, resolvedModule, moduleReference);
errorOnImplicitAnyModule(/*isError*/ noImplicitAny && !!moduleNotFoundError, errorNode, resolvedModule!, moduleReference); // TODO: GH#18217
}
// Failed imports and untyped modules are both treated in an untyped manner; only difference is whether we give a diagnostic first.
return undefined;
Expand All @@ -3484,6 +3496,9 @@ namespace ts {
}
else {
const tsExtension = tryExtractTSExtension(moduleReference);
const isESMFile = currentSourceFile.impliedNodeFormat === ModuleKind.ESNext;
const isExtensionlessRelativePathImport = pathIsRelative(moduleReference) && !hasExtension(moduleReference);
const resolutionIsNode12OrHigher = getEmitModuleResolutionKind(compilerOptions) >= ModuleResolutionKind.Node12;
Comment thread
gabritto marked this conversation as resolved.
Outdated
if (tsExtension) {
const diag = Diagnostics.An_import_path_cannot_end_with_a_0_extension_Consider_importing_1_instead;
const importSourceWithoutExtension = removeExtension(moduleReference, tsExtension);
Expand All @@ -3503,6 +3518,18 @@ namespace ts {
hasJsonModuleEmitEnabled(compilerOptions)) {
error(errorNode, Diagnostics.Cannot_find_module_0_Consider_using_resolveJsonModule_to_import_module_with_json_extension, moduleReference);
}
else if (isESMFile && resolutionIsNode12OrHigher && isExtensionlessRelativePathImport) {
Comment thread
gabritto marked this conversation as resolved.
Outdated
const absoluteRef = getNormalizedAbsolutePath(moduleReference, getDirectoryPath(currentSourceFile.path));
const suggestedExt = suggestedExtensions.find(([actualExt, _importExt]) => host.fileExists(absoluteRef + actualExt))?.[1];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probes the FS - do we have any concerns there from a performance standpoint?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally the answer is “no” when we’re about to emit an error, but it is kind of notable that nowhere else in the checker uses fileExists. You could use host.getSourceFile instead which would (for all of our own TypeCheckerHost implementations) only see if there was a file by that name we already loaded for some reason. This will often be sufficient given a project’s default includes glob, but won’t work in general, so it kind of depends on whether we want this message to be a sure thing or a medium-effort heuristic. I kind of lean toward host.fileExists is already there, so it’s probably ok to use...

On the other hand, this error message could be a pretty hot path if you take a big codebase and flip it from --module commonjs to node12 before updating anything. But doing this work at some point seems unavoidable if we want to give the better error.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we can move into the program construction phase then? Do we ever build errors there?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're going to probe a bunch of paths in the same directory, it can be faster to use readdir and then compare against the results yourself.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some danger that people will see this thousands of times when they first change the setting, but I'm a little reluctant to "optimize" it without some way to establish that the change helped. Probably better to keep it simple until we measure a problem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we can move into the program construction phase then? Do we ever build errors there?

After thinking about this more, I think maybe the ideal implementation would probe the filesystem during module resolution—when ESM-mode module resolution fails, it could try CJS-mode resolution and attach the result to the ResolvedModuleWithFailedLookupLocations result. The checker would then call getResolvedModuleWithFailedLookupLocationsFromCache (already on program but needs to be added to TypeCheckerHost) when it sees an unresolved import to check and see if CJS-mode resolution would have worked, and if so, exactly what file it would have resolved to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably worth noting that the fileExists check may not even be sufficient in the presence of export (or import) maps - if you wanna know if the specifier-with-some-specific-extension would have resolved, you pretty much need to rerun the whole resolution process now. Precaching some extra specifier resolutions could work, but it certainly wouldn't be free to do (especially given that all of .js, .cjs, and .mjs specifiers would need testing).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Andrew Branch (@andrewbranch)'s suggestion is a good one for suggesting extensions. I don't think I could get this ready for the RC, though. Does it still make sense to include this PR as is? I think the error message without the suggestion is going to remain as is, so that should go in. I think it would only make sense to remove the extension suggestion message/check if we have performance concerns over calling fileExists. Is that the case?

if (suggestedExt) {
error(errorNode,
Diagnostics.Cannot_use_a_relative_import_path_without_an_extension_in_an_ES_module_when_moduleResolution_is_node12_or_higher_Did_you_mean_0,
Comment thread
gabritto marked this conversation as resolved.
moduleReference + suggestedExt);
}
else {
error(errorNode, Diagnostics.Cannot_use_a_relative_import_path_without_an_extension_in_an_ES_module_when_moduleResolution_is_node12_or_higher);
}
}
else {
error(errorNode, moduleNotFoundError, moduleReference);
}
Expand Down
8 changes: 8 additions & 0 deletions src/compiler/diagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -3341,6 +3341,14 @@
"category": "Error",
"code": 2833
},
"Cannot use a relative import path without an extension in an ES module when '--moduleResolution' is 'node12' or higher.": {
Comment thread
gabritto marked this conversation as resolved.
Outdated
"category": "Error",
"code": 2834
},
"Cannot use a relative import path without an extension in an ES module when '--moduleResolution' is 'node12' or higher. Did you mean '{0}'?": {
"category": "Error",
"code": 2835
},

"Import declaration '{0}' is using private name '{1}'.": {
"category": "Error",
Expand Down
2 changes: 1 addition & 1 deletion src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5983,7 +5983,7 @@ namespace ts {
export enum ModuleResolutionKind {
Classic = 1,
NodeJs = 2,
// Starting with node12, node's module resolver has significant departures from tranditional cjs resolution
// Starting with node12, node's module resolver has significant departures from traditional cjs resolution
// to better support ecmascript modules and their use within node - more features are still being added, so
// we can expect it to change over time, and as such, offer both a `NodeNext` moving resolution target, and a `Node12`
// version-anchored resolution target
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/src/bar.mts(1,21): error TS2835: Cannot use a relative import path without an extension in an ES module when '--moduleResolution' is 'node12' or higher. Did you mean './foo.mjs'?
/src/bar.mts(2,21): error TS2834: Cannot use a relative import path without an extension in an ES module when '--moduleResolution' is 'node12' or higher.


==== /src/foo.mts (0 errors) ====
export function foo() {
return "";
}

// Extensionless relative import in an ES module
==== /src/bar.mts (2 errors) ====
import { foo } from "./foo";
~~~~~~~
!!! error TS2835: Cannot use a relative import path without an extension in an ES module when '--moduleResolution' is 'node12' or higher. Did you mean './foo.mjs'?
import { baz } from "./baz";
~~~~~~~
!!! error TS2834: Cannot use a relative import path without an extension in an ES module when '--moduleResolution' is 'node12' or higher.
foo;
baz;

25 changes: 25 additions & 0 deletions tests/baselines/reference/moduleResolutionWithoutExtensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//// [tests/cases/conformance/externalModules/moduleResolutionWithoutExtensions.ts] ////

//// [foo.mts]
export function foo() {
return "";
}

// Extensionless relative import in an ES module
//// [bar.mts]
import { foo } from "./foo";
import { baz } from "./baz";
foo;
baz;


//// [foo.mjs]
export function foo() {
return "";
}
// Extensionless relative import in an ES module
//// [bar.mjs]
import { foo } from "./foo";
import { baz } from "./baz";
foo;
baz;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
=== /src/foo.mts ===
export function foo() {
>foo : Symbol(foo, Decl(foo.mts, 0, 0))

return "";
}

// Extensionless relative import in an ES module
=== /src/bar.mts ===
import { foo } from "./foo";
>foo : Symbol(foo, Decl(bar.mts, 0, 8))

import { baz } from "./baz";
>baz : Symbol(baz, Decl(bar.mts, 1, 8))

foo;
>foo : Symbol(foo, Decl(bar.mts, 0, 8))

baz;
>baz : Symbol(baz, Decl(bar.mts, 1, 8))

22 changes: 22 additions & 0 deletions tests/baselines/reference/moduleResolutionWithoutExtensions.types
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
=== /src/foo.mts ===
export function foo() {
>foo : () => string

return "";
>"" : ""
}

// Extensionless relative import in an ES module
=== /src/bar.mts ===
import { foo } from "./foo";
>foo : any

import { baz } from "./baz";
>baz : any

foo;
>foo : any

baz;
>baz : any

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// @moduleResolution: node12
// @module: node12

// @filename: /src/foo.mts
export function foo() {
return "";
}

// Extensionless relative import in an ES module
// @Filename: /src/bar.mts
import { foo } from "./foo";
import { baz } from "./baz";
foo;
baz;