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

Supports exports pointing to a TypeScript file, which keeps consistent with main #48369

Closed
pd4d10 opened this issue Mar 22, 2022 · 14 comments · Fixed by #48563
Closed

Supports exports pointing to a TypeScript file, which keeps consistent with main #48369

pd4d10 opened this issue Mar 22, 2022 · 14 comments · Fixed by #48563
Assignees
Labels
Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript

Comments

@pd4d10
Copy link

pd4d10 commented Mar 22, 2022

Bug Report

In some cases, we want to import from the source code instead of the compiled, which is perfectly implemented by the main field:

// package.json
{
  "main": "./src/index.ts"
}

While it seems not to work at the newly introduced exports field

🔎 Search Terms

#46452
#48084

🕗 Version & Regression Information

  • This is the behavior in every version (the latest stable and nightly versions) I tried

⏯ Playground Link

This is about the package.json behavior, and the playground seems can't reproduce it.

💻 Code

// the library's package.json
{
  "exports": {
    ".": "./src/index.ts"
  }
}
// trying to use this library
import { xxx } from 'my-lib'

🙁 Actual behavior

It would show a ts2037 error message:

Cannot find module 'my-lib' or its corresponding type declarations. ts(2307)

🙂 Expected behavior

Works correctly, just like the main field's behavior.

@Jamesernator
Copy link

Jamesernator commented Mar 22, 2022

ESM/packages support in TS is not shipping yet, but it's hopefully gonna ship in TypeScript 4.7.

You can try it out by using typescript@next, you'll need to change a tsconfig setting though as mentioned here in the handbook

In this specific case you might be wanting to use (based on the handbook description) the following to specify types:

{
    "exports": {
        ".": {
            "types": "./src/index.ts"
        }
    }
}

@pd4d10
Copy link
Author

pd4d10 commented Mar 22, 2022

ESM/packages support in TS is not shipping yet, but it's hopefully gonna ship in TypeScript 4.7.

You can try it out by using typescript@next, you'll need to change a tsconfig setting though as mentioned here in the handbook

Thanks for providing the information!

To clarify a little, I think the key point of this issue is consistency, because the IntelliSense works when the main field points to a TS file, even in the absence of the types field. So would like to discuss whether the newly introduced exports should have a similar behavior.

@Jamesernator
Copy link

Jamesernator commented Mar 23, 2022

Currently TypeScript doesn't actually officially support importing modules with a .ts extension. The fact that "main" works today at all is probably more of an accident from however TypeScript is parsing and resolving the fields in package.json.

The current advice in the handbook is for packages to use "types": "./src/index.ts", not the "main" field (even outside of "exports").

My guess is you're either wanting this behaviour for either usage in Deno or in something like ts-node. In the first case, Deno has it's own language server because of the aformentioned issue of not supporting .ts, and in the latter (e.g. ts-node) yeah it's a bit of a pain you can't currently put "main": "./dist/index.js" and have ts-node resolve it correctly yet, although that is arguably a bug in ts-node (and I think it might be possible to configure ts-node to perform resolution in packages, but I can't remember how).

@RyanCavanaugh
Copy link
Member

@weswigham thoughts?

@weswigham
Copy link
Member

weswigham commented Mar 28, 2022

Uhh, I mean, I guess it should work, given how our resolution historically proceeds, and currently doesn't. Workaround for now is just to say .js instead of .ts in the export map - that's enough to get us to find the source .ts.

Fix is pretty simple for us - in moduleNameResolver.ts in loadJSOrExactTSFileName we currently have a check for a bunch of .d.tsy extensions to explicitly allow exact matches to those - we could add all the .tsy extensions to that list, too (at least when we're looking for TS files), for the desired effect here.

@weswigham
Copy link
Member

Like, this is probably the change we'd need to make to support this, I suppose.

@DanielRosenwasser
Copy link
Member

Is this a possible foot-gun though? Is there a reason why this behavior is desirable?

@milesj
Copy link

milesj commented Apr 5, 2022

Just my 2 cents, but if you want to provide TS files as the entry point, just use main? It doesn't need to use exports.

@Jamesernator
Copy link

Just my 2 cents, but if you want to provide TS files as the entry point, just use main? It doesn't need to use exports.

"exports" can do more than just specify the main entry point, like if this were supported then things like:

{
    "exports": {
        "./subname": "./src/libs/subname-impl.ts"
    }
}

would presumably be supported as well.

Although there is the question as to whether actually supporting this is a good idea or not, like the fact "main" works at all today seems fairly inconsistent given that TypeScript doesn't usually allow importing directly from .ts extensions. (i.e. import ... from "./file.ts"; is not supported).


In either case, it would probably be worth making it so that resolution in node12/nodenext is at least consistent for both "main" and "exports", either officially supporting .ts resolutions or not.

Existing resolution probably relies on it in places, so removing support there would probably not be a non-breaking change. Such places going forward though probably won't be able to rely on it with "exports" being a thing anyway, so if it's not supported in "exports" in node12/nodenext resolution, it's probably better to turn it off for "main" as well.

@vjpr
Copy link

vjpr commented Apr 27, 2022

Same error over here. I actually cannot get anything to do with exports working with 4.7.0-beta.

I have tried all iterations:

  "exports1": {
    ".": {
      "types1": "./src/index.ts",
      "import": {
        "default": "./lib/index.js",
        "types": "./lib/index.js",
        "types1": "./src/index.ts",
        "default1": "./src/index.ts"
      }
    }
  },
  "exports2": {
    ".": "./lib/index.js"
  },
  "exports": {
    ".": {
      "import":"./lib/index.js"
    }
  }

However main works fine.

Has anyone got an example of the new exports working?

PS. When importing I am using symlinked packages in pnpm repo. Maybe this is the problem...

@milesj
Copy link

milesj commented Apr 27, 2022

@vjpr I got this to work rather easily as seen here: https://github.com/milesj/packemon/pull/122/files

If you look at my test file here https://github.com/milesj/packemon/blob/master/tests/testPackemonImports.ts, you'll see that I'm importing from packemon/babel, which is using exports. I'm also ensuring that referential equality works as expected.

Be sure your moduleResolution is set to nodenext.

It also depends on how you're trying to load the package and from what context/tool.

@vjpr
Copy link

vjpr commented Apr 27, 2022

@milesj Try removing the main field. For me this then starts throwing the "cannot find module" error.

@vjpr
Copy link

vjpr commented Apr 27, 2022

@milesj Your package is also set to "type": "commonjs",...does it work if you change to module?

@vjpr
Copy link

vjpr commented Apr 27, 2022

After debugging the source code I now understand the issue better which is just as weswigham said:

Workaround for now is just to say .js instead of .ts in the export map - that's enough to get us to find the source .ts.
#48369 (comment)

Just to make it clear for anyone else:

function loadModuleFromFileNoImplicitExtensions(extensions: Extensions, candidate: string, onlyRecordFailures: boolean, state: ModuleResolutionState): PathAndExtension | undefined {
// If that didn't work, try stripping a ".js" or ".jsx" extension and replacing it with a TypeScript one;
// e.g. "./foo.js" can be matched by "./foo.ts" or "./foo.d.ts"
if (hasJSFileExtension(candidate) || (fileExtensionIs(candidate, Extension.Json) && state.compilerOptions.resolveJsonModule)) {
const extensionless = removeFileExtension(candidate);
const extension = candidate.substring(extensionless.length);
if (state.traceEnabled) {
trace(state.host, Diagnostics.File_name_0_has_a_1_extension_stripping_it, candidate, extension);
}
return tryAddingExtensions(extensionless, extensions, extension, onlyRecordFailures, state);
}
}
function loadJSOrExactTSFileName(extensions: Extensions, candidate: string, onlyRecordFailures: boolean, state: ModuleResolutionState): PathAndExtension | undefined {
if ((extensions === Extensions.TypeScript || extensions === Extensions.DtsOnly) && isDeclarationFileName(candidate)) {
const result = tryFile(candidate, onlyRecordFailures, state);
return result !== undefined ? { path: candidate, ext: forEach(supportedDeclarationExtensions, e => fileExtensionIs(candidate, e) ? e : undefined)! } : undefined;
}
return loadModuleFromFileNoImplicitExtensions(extensions, candidate, onlyRecordFailures, state);
}

loadJSOrExactTSFileName will only resolve for .dts files. If this fails, then it tries to remove the .js extension and add .ts extension.

The fix is just to remove && isDeclarationFileName(candidate).

Confusing: {node: './lib/index.js'}

I use {exports: {node: './lib/index.js'}}. TS will simply swap the extension here and will not find ./lib/index.ts, because it is in ./src/index.ts. So then you think to add {types: './src/index.ts'}. This is REALLY confusing, because you are specifying an additional condition just for TS, but you must use the .js ext. Took a long time to figure out this was the right approach.

My use case for allowing .ts

I use a Node.js loader to transpile TS on the fly, and I use a condition called transpile and my export map looks like:

exports: {
  "./": {
    transpile: './src/index.ts`,
    node: './lib/index.js'
  }
}

Then I can just run my code with CONDITIONS=transpile node myapp.js to run in transpilation mode and everything works great.

And also with webpack, I want it to use browser field which should point to a .ts file.


Export condition resolution

Also just want to leave this here to show how TS chooses which export map condition to use.

else if (typeof target === "object" && target !== null) { // eslint-disable-line no-null/no-null
if (!Array.isArray(target)) {
for (const key of getOwnKeys(target as MapLike<unknown>)) {
if (key === "default" || state.conditions.indexOf(key) >= 0 || isApplicableVersionedTypesKey(state.conditions, key)) {
const subTarget = (target as MapLike<unknown>)[key];
const result = loadModuleFromTargetImportOrExport(subTarget, subpath, pattern);
if (result) {
return result;
}
}
}
return undefined;
}
else {
if (!length(target)) {
if (state.traceEnabled) {
trace(state.host, Diagnostics.package_json_scope_0_has_invalid_type_for_target_of_specifier_1, scope.packageDirectory, moduleName);
}
return toSearchResult(/*value*/ undefined);
}
for (const elem of target) {
const result = loadModuleFromTargetImportOrExport(elem, subpath, pattern);
if (result) {
return result;
}
}
}
}

  • Walks through keys in the export object in order.
  • If key is 'default', uses that.
  • If key is in state.conditions, uses that. conditions can only be:
    • For EsmMode, [node, import, types]
    • For CommonJS, [node, require, types]
    • Also, conditions can vary per-package depending on the file format.
  • If there is a matching versioned types key (e.g. {exports: {'./': {'types@4.7.0-beta': './lib/index.d.ts'}}}`, use that.

Summary

For now, you will always need to add a special types condition key pointing to a ts file with the extension changed to js.

Also note the need to include the '.js' extension in wildcards for types...

{
  "exports": {
    ".": {
      "node": "./lib/index.js",
      "types": "./src/index.js" <-- Even though the real file is `index.ts`.
    },
    "./shared/*": {
      "browser": "./src/shared/*",
      "types": "./src/shared/*"
    }
  }
}

Also note for webpack you need to use a plugin (https://github.com/softwareventures/resolve-typescript-plugin) that will try to resolve .js files to .ts.


All this should be documented better...

alexlafroscia added a commit to alexlafroscia/demo-ts-esm that referenced this issue Jun 14, 2022
While digging deeper into my initial complain around nothing validating that TypeScript was importing `.js` files with an extension, I eventually came across this very helpful comment on a thread in the TypeScript issues:

microsoft/TypeScript#48369 (comment)

After setting the `moduleResolution` to `nodenext`, suddenly _exactly_ what I expected happens!

- If no extension is provided at all, TypeScript fails to compile
- If you accidentally provide the `.ts` extension, as you may be inclined to attempt, TypeScript fails to compile

Vitest will allow you to run your tests without extensions in your imports, but a `tsc` pass on the files (from your editor's language server, perhaps) will complain
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experience Enhancement Noncontroversial enhancements Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants