microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.77k stars 12.46k forks source link

allow voluntary .ts suffix for import paths #37582

Closed timreichen closed 1 year ago

timreichen commented 4 years ago

Search Terms

.ts suffix imports extension

Suggestion

Typescript doesn't recognize file imports with .ts suffix. Allow voluntary .ts to be added to import paths.

Use Cases

It seems right to be able to use a correct path to a file without magic resolution. This would help to align with deno which uses mandatory suffixes o files.

Examples

let import a from "path/to/a.ts" behave the same as import a from "path/to/a"

Checklist

My suggestion meets these guidelines:

timreichen commented 4 years ago

After some more research I wonder if that behavior could even be more guided by a compiler option like --noImplicitSuffix. That would be in alignment with --noImplicitAny and give the developer the choice, yet push for a valid, not "magical" resolved path if not set.

What are your thoughts on that?

Jack-Works commented 4 years ago

35148

zacnomore commented 4 years ago

35148

Read through the above and it seems to boil down to multiple build targets with different file extensions throws a wrench in things when supporting using the file extensions on imports. Where it diverges in my mind, and hopefully @Jack-Works you can illuminate this a bit, is that we'd be importing .ts extensions which would have to be rewritten anyways.

resynth1943 commented 3 years ago

So... where are we actually at with this?

Scattered issues / feature requests don't help, and the confusing (and somewhat untimely) response(s) from official TypeScript members haven't helped me understand how this feature will be implemented (it will have to be, eventually).

All I can ascertain is that this is has yet to be resolved.

Can someone explain in plain English when and how these multiple issues will be fixed, please?

tonygiang commented 3 years ago

+1

If I turn on resolveJsonModule and put a MatchmakingConfig.ts and a MatchmakingConfig.json in the same path, a nasty unexpected behavior awaits:

// this results in MatchmakingConfig.json being imported, not MatchmakingConfig.ts!
import MatchmakingConfig from "./path/to/MatchmakingConfig";

Allowing .ts suffix will let us specify .ts files specifically.

cshaa commented 3 years ago

I would love to see this issue resolved. Without the support for imports with a .ts extension, it's literally impossible to create a repository that works with Deno, has TS language services and is buildable with tsc. It seems like an arbitrary limitation on the TypeScript side 😕️

cztomsik commented 3 years ago

@m93a I am using esbuild for transpilation/bundling and the resulting bundle then works in both deno and node. It's a workaround but at least it works.

patrickarlt commented 3 years ago

This would also help for things like https://github.com/import-js/eslint-plugin-import/issues/2111#issuecomment-915663474. Right now eslint-plugin-import thinks that TypeScript files should only import .ts files or omit the extension.

ghost commented 3 years ago

Typescript doesn't recognize file imports with .ts suffix. Allow voluntary .ts to be added to import paths.

JavaScript doesn't differentiate between file extensions. It simply doesn't care. TypeScript should do the same: simply accept any file, and use it's MIME type instead.

cawoodm commented 3 years ago

This is pretty crazy with Deno:

andrewbranch commented 3 years ago

@cawoodm do you have the Deno extension for VS Code enabled? https://deno.land/manual@v1.14.3/vscode_deno

My understanding is that the IDE experience with Deno is pretty well solved, so I’d like to understand what exactly the scenarios are that need work.

it's literally impossible to create a repository that works with Deno, has TS language services and is buildable with tsc

@m93a when you say “buildable with tsc,” what exactly are you looking for? Type checking with noEmit? Does the deno CLI not have something for that?

jacobmischka commented 3 years ago

Deno isn't the only platform where this is an issue, building TypeScript projects with other bundlers like Babel or Metro for React Native also are problematic. Yes, in those instances one can omit the extension entirely, but many of us do not want to.

The primary problem is that this just seems so needless, the build works fine with the .ts extension and TypeScript knows the source files are there, it just refuses to look at them. None of us want any additional behavior, we would just like a way to opt out of the error and for TypeScript to parse the source files anyway so that the LSP server will work.

andrewbranch commented 3 years ago

I think you’re right that this would be fairly easy from an implementation standpoint, but there are two main reasons why we don’t want to move forward without careful consideration of the full problem space:

  1. If we simply add a flag that allows module resolution to work with .ts files, it will 100% contribute to the misconception that TypeScript does, can, or should emit JS files with the transformation import "./foo.ts" → import "./foo.js". We already have angry mobs demanding that this happen for import "./foo" → import "./foo.js" so if we allowed .ts extensions on there without it being very clear what use cases it is intended to serve, it would just pour fuel on that fire.
  2. Such a flag would look like a gift to Deno users, but it would actually be woefully incomplete for them. There are obviously a lot of other parts of module resolution that are either specific to Deno or shared between Deno and the browser that we don’t currently support, and I think those users would be confused and disappointed that we stopped so far short of proper support for these things.

Don’t get me wrong, I want us to solve all of these problems, but we’re still in the phase where we’re trying to develop a thorough understanding of all the different reasons people want this and what the existing solutions are.

JasonKleban commented 3 years ago

I’m not convinced that node12/nodenext are appropriate module resolution modes if you’re writing for the browser or for a bundler.

What about for isomorphic server-side-rendered code?

andrewbranch commented 3 years ago

That’s a good question. You would clearly need to write that code using the lowest common denominator of resolution features supported by all of your targets. The combinatorics of this mean that such a mode will probably never be supported by TypeScript, so you’d have two options:

There are some combinations of targets that seem to have no possible overlap. For example, if you want to write a relative import path to a TS file in Deno, you need to include the .ts extension. But if you want to compile that same code to work in the browser, you might be stuck, at least if you use tsc alone, as our current thinking is that if we let you write .ts file extensions we would force you to compile with --noEmit. Of course, there are a lot of other tools that could come into play and help you out here. Vite seems particularly well-positioned to help with situations like these since it’s ESM-first and transpiles TS without checking—this means you could satisfy our type checker by telling it --noEmit while Vite handles your emit through ESBuild.

But overall, I think we probably need to start by solving the most common individual scenarios first before we figure out how they can be endlessly combined.

Jamesernator commented 3 years ago

There are some combinations of targets that seem to have no possible overlap. For example, if you want to write a relative import path to a TS file in Deno, you need to include the .ts extension. But if you want to compile that same code to work in the browser, you might be stuck, at least if you use tsc alone, as our current thinking is that if we let you write .ts file extensions we would force you to compile with --noEmit.

Well there's three (possible) solutions here actually:

  1. The commonly asked for extension rewriting, this is fairly trivial for static imports but is quite a bit more tricky for dynamic imports
  2. Require a different outDir and emit as .ts except without types, this is kind've strange but browsers do not care about extensions so as long as the server served the files with text/javascript content type they will just work
  3. Emit as .js but preserve .ts specifiers, while browsers won't quite work out of the box, once support for import maps is wider it'll be possible to map .js files to their .ts counterparts
cawoodm commented 3 years ago

@cawoodm do you have the Deno extension for VS Code enabled? https://deno.land/manual@v1.14.3/vscode_deno My understanding is that the IDE experience with Deno is pretty well solved...

I did indeed but there were teething issues which have since disappeared. import { Foo } from "./types.ts"; is now accepted.

kitsonk commented 2 years ago

@andrewbranch we (@denoland) are obviously interested in trying to solve this problem. One of the things that we have started to work on (@dsherret taking the lead) is a stand-alone emitter that would export Deno explicit extensions with something that would be extensionless or have JavaScript extensions as well as solve other challenges of consuming code written for Deno in other runtimes like Node.js (like supporting remote modules).

I think this isn't only just a Deno issue, as recent changes in TypeScript 4.5, module resolution and specifier re-writting is a complicated subject, which causes all sorts of problems, as bundlers expect one thing and runtimes support a different set.

My personal opinion is that it might be time to consider a pluggable resolver and loader for tsc? Something where a resolver plugin would be able to resolve a specifier, load the content, and provide a "emit" specifier so tsc doesn't have to get into any fights about what solution works?

DanielRosenwasser commented 2 years ago

I think this isn't only just a Deno issue, as recent changes in TypeScript 4.5, module resolution and specifier re-writting is a complicated subject, which causes all sorts of problems, as bundlers expect one thing and runtimes support a different set.

This is something @andrewbranch and I have discussed - the space is messy, but there is some overlap between Deno and bundlers in this regard.

My personal opinion is that it might be time to consider a pluggable resolver and loader for tsc? Something where a resolver plugin would be able to resolve a specifier, load the content, and provide a "emit" specifier so tsc doesn't have to get into any fights about what solution works?

I don't want to get too off track, but I think the problem with a pluggable resolver is that the resolver doesn't give everything else for free. For example, you don't get the parts of the language service that know what the best path is to auto-import from, and whether that import should have a .ts extension or a .js extension, etc. - and those are problems beyond plugins that may or may not be doing the most optimal thing (e.g. calling the right APIs, caching in the right places, etc.)

thescientist13 commented 2 years ago

Just wanted to chime as guided from my comment in the other thread and alluded to with Vite here and Snowpack, lots of tools besides Deno want to provide first class support for TS, it certainly stands as a testament to the great work of the team! But all of this is being made possible via ESM, but the ESM spec requires an extension. So in an ESM based world, omitting the extension is definitely going against the grain, and I suspect it will be so more and more going forward.

So in the interim, the current choices seem to be:

Which leaves those wanting to provide first class support for TS between a rock and a hard place.


On another, note, perhaps this is something that could be reconciled through import assertions? Now that JSON and CSS are loadable via ESM, perhaps this concept could be leveraged and applied here? 🤔

import { Foo } from "./foo.js" assert { type: "ts" };

Something that gives a bit of hinting for both language and tooling authors alike?

jespertheend commented 2 years ago

but we’re still in the phase where we’re trying to develop a thorough understanding of all the different reasons people want this

There's two reasons why I want this:

donmccurdy commented 2 years ago

I'm finding this issue to be the single biggest obstacle to writing a TypeScript library that works in all environments (Web, Node, and Deno). I'm compiling the library to a single file with microbundle. Ideally I could run the same test suite across both Node and Deno...

ts-node tape tests/*.test.ts

deno test --import-map tests/deno-import-map.json --no-check tests/

... but the supported import statements for the two environments are mutually exclusive. 😕

andrewbranch commented 2 years ago

@jespertheend can you elaborate on the first of your two bullet points? I don’t understand how that one is related to writing .ts in import statements. If all your files are .js to begin with, everything should work with .js in import statements, no?

jespertheend commented 2 years ago

Ah sorry, for a second I thought this issue was about url imports as well as the .ts extension. When I wrote that I was trying to import {assertEquals} from "https://deno.land/std@0.118.0/testing/asserts.ts";, which would still suffer from Cannot find module. Did you mean to set the 'moduleResolution' option to 'node', or to add aliases to the 'paths' option? ts(2792) even if .ts imports are allowed.

frank-dspeed commented 2 years ago

@andrewbranch my observation about typescript at all and its missconception is that it does not simply relay on type inference + JSDOC expanded support then every type would get shipped via the original js or not and no additional resolve needed i never use the .ts extension and many senior devs do agree with me on that.

We are the most happy typescript users no issues that are resolve related or missing types they are all in the JS files

alllowJs + checkJs

is the holly graal

we even do create debug builds where we replace the types with real assert calls to get runtime typechecking.

Lilja commented 2 years ago

If we simply add a flag that allows module resolution to work with .ts files, it will 100% contribute to the misconception that TypeScript does, can, or should emit JS files with the transformation import "./foo.ts" → import "./foo.js". We already have angry mobs demanding that this happen for import "./foo" → import "./foo.js" so if we allowed .ts extensions on there without it being very clear what use cases it is intended to serve, it would just pour fuel on that fire.

I would like to state that the argument for ./foo → ./foo.js is not nearly as strong as for ./foo.ts → ./foo.js. The .ts/.d.ts file extension very explicitly adds which file that is indented to import. In contrast to ./foo which could mean ./foo.ts, ./foo/index.ts, ./foo/index.d.ts. ./foo is a lot more ambiguous, while ./foo.ts is a lot more explicit.

I'm all for removing the amount of ambiguity so I'll gladly help the typescript compiler point to which file I'm actually referring to and if that in turn will make the typescript compiler turn those file extensions into .js to help with ESM compatibility, then it's terrific. Explicit is almost always better than implicit, I wouldn't lose sleep over people who still want ./foo -> ./foo.js if *.ts was resolved to .js while files without file extensions wouldn't.

arendjr commented 2 years ago

@andrewbranch wrote:

If we simply add a flag that allows module resolution to work with .ts files, it will 100% contribute to the misconception that TypeScript does, can, or should emit JS files with the transformation import "./foo.ts" → import "./foo.js". We already have angry mobs demanding that this happen for import "./foo" → import "./foo.js" so if we allowed .ts extensions on there without it being very clear what use cases it is intended to serve, it would just pour fuel on that fire.

FWIW, I was not aware of this issue when I filed https://github.com/microsoft/TypeScript/issues/49083, but it appears I’m now part of the angry mob you refer to Sorry, I misread: I’m not asking for a import "./foo" → import "./foo.js" transformation, but I will happily join an angry mob asking for the import "./foo.ts" → import "./foo.js" one :)

I appreciate you seem to be willing to actually work on this, but unfortunately it seems we’re not getting close to a solution. Meanwhile the node16 module resolution is about to be released which is also pouring fuel on that same fire in its own way.

As @Lilja states, support doesn’t need to extend beyond explicit, full paths. But it would really be great if we could get some support for this.

arendjr commented 2 years ago

As for @andrewbranch’s other point:

Such a flag would look like a gift to Deno users, but it would actually be woefully incomplete for them. There are obviously a lot of other parts of module resolution that are either specific to Deno or shared between Deno and the browser that we don’t currently support, and I think those users would be confused and disappointed that we stopped so far short of proper support for these things.

This seems to be a classic defeatist argument. Just because you cannot solve all problems should not be an argument to not solve any.

inca commented 2 years ago

First, let me start with a (hopefully) constructive suggestion: let's please remove the "support extensionless imports" from the main scope and stop discussing it alongside the "add support for importing .ts extension". In my opinion it adds unnecessary controversy to any extension-related problem — and it is more or less clear that probing for different extensions is not feasible for numerous of platforms like Web and Deno (but not limited to those two). So I suggest we stop mixing these two problems and assume that we do not want to write extensionless imports and have TSC magically guess what we meant.


Now, on "import .ts" thing: since I've read a lot of different interpretations of "how relative imports in TypeScript should work", I'd like to draw a quick summary before offering my own perspective. As of May 2022 things seems to look like this:

Now as a human the way I have always imagined relative paths work is: you see something like ./foo/bar you think "ok, where am I right now?" — depending on the answer to that question you'll know how to resolve that relative path to an absolute one.

Specifically:

Finally, in my opinion, saying import './foo.js' doesn't make too much sense not only because the actual file doesn't exist relatively to the source I'm editing, but also because it says .js — but implies also importing .d.ts for the type checking to work.

So given all the above & beyond, I think it's no longer fair nor feasible to simply shrug off the issue with a "wontfix because of a design goal". Big issues like these may warrant a change in the design goals if it's for a greater good.

frank-dspeed commented 2 years ago

@inca your observations are all correct but i tracked that also down even more deep.

Rule of Thumb

.ts files are never part of the code they are additional tooling files that can lead to declarations and or code but the code is optional. If you make .ts files part of your code your entering dark worlds where cat's can die.

conclusion

as .js + generated or handwritten .d.ts is the only usabele formart to author and ship without quirks the whole issue should be closed wont-fix is the right strategie as .ts needs to die anyway.

Conaclos commented 2 years ago

@frank-dspeed Unfortunately it is currently impossible to correctly type a file using declaration files or JSDocs.

They nicely allow to type-check code boundaries. However they fail to type-check local code.

Indeed if you use the strictest TypeScript settings (strictNullCheck, ...), then it always ends with a code that does not pass the type-checker. Workarounds are still sub-optimal or there are simply non-existent.

Moreover, JSDocs are less expressive and more verbose than TypeScript (as an example typedefs are unpleasant to write).

Unfortunately, declaration files are not an option here: when you type-check a file it odes not use its declaration file to infer its function/constant types. I opened an issue about that #44946.

The best hope could be the recent TC89 proposal to natively support type annotations in JavaScript. However, we need to wait several years...

Another solution could be to switch to Flow that has a strong support for types in comments and allow type annotations in js files.

cefn commented 2 years ago

@arendjr wrote

I will happily join an angry mob asking for the import "./foo.ts" → import "./foo.js"

As a villager in the JS and TS ecosystem, my own fork is impossibly ambitious, so I'm left only with my pitchfork. I would join the mob too :)

This was a frustrating error to encounter as part of an ESM migration in which I had actually got nothing wrong (by way of "type", "module", "moduleResolution" and ts-node ESM loader, each of which was hard-won knowledge with little or no meaningful feedback). Then the first time I used the configuration 'in anger', (with a relative import), I found a new inexplicable error. I couldn't expect colleagues to go through this to get perfectly functional code to run. This endangers the adoption of the tools.

I interpreted the inability to resolve files (even when the path was exactly right) as something wrong in my config as I could see the file, and had always imported .ts files without suffix before. This meant another whole round of experimenting with flags and config in 4 places to soothe the ESM dependency monster. I tried an explicit .ts which seemed reasonable to clarify the path and it was rejected. .js wouldn't have occurred to me in a million years.

merrywhether commented 2 years ago

With the rise of babel-ts, esbuild, and swc providing a variety of options for people looking to work with TypeScript, is it necessary for tsc the type-checker (as the avatar of TypeScript the language specification) to stay perfectly in sync with tsc the compiler/transpiler? Having the type system be a superset of the compiler capabilities would free up language design from generalizable implementation complexity. Language design would still need to consider complexity, but pushing some things out into community-only implementations would free any single solution from having to accommodate every usage pattern and could instead have its own opinions about which combinations are supported. That means that tsc transpilation can continue to focus on ease-of-use/-configuration and hew towards principles like "no rewriting JavaScript" while something like babel transpilation can embrace its wide support of happily rewriting lots of JavaScript via the unlimited plugin ecosystem. I think web devs are used to the idea of "exotic features require more config/tools" and manage those trade-offs all the time, so this wouldn't be a major departure.

In this case, TypeScript would be freed up to add the flag for type-checking purposes while the "easy" route of using only tsc for transpilation could continue its design arc and principles. These extensions could pass-through tsc unchanged and using them would require replacing or augmenting tsc in a project's build pipeline, just like people have to do if they opt into styled-components or relay or any of the many other transform-requiring libraries out there. And then projects can use finely-/singly-tuned solutions that enable ./foo -> ./foo.js or ./foo.ts -> ./foo.js or whatever else crops up if they feel the choice is worth the complexity, and everyone is happily building apps the way they want.

This would obviously be a change of stance for TypeScript, but it would enable a path forward for things like this. And making this divergence explicit should quell the "angry mobs" who you can just point to another transpilation solution if they want to get alternative transpilation outcomes.

frank-dspeed commented 2 years ago

@merrywhether there is something new upcoming i hope i get it finished till christmas but i am Implementing the Whole Typescript Language at present on Truffle as also a Special GraalJS Branch that includes https://github.com/tc39/proposal-type-annotations

This will allow you to base on that for your own Language Implementations.

Graal stands for „General Recursive Applicative and Algorithmic Language“. There is something we call GraalVM it is able to implement any „General Recursive Applicative and Algorithmic Language“. via the so called Truffle Framework.

So You can see it as Coding Language Development framework that is fully debuggable via devtools as typescript is.

Update because got confused emoji

it already offers a nodejs compatible runtime where Typescript already runs on and we can already do modifications to it even when it is implemented in ECMAScript like it is and even when it uses at present nodejs modules for tsc. The linked nodejs version does not run V8!!!! There is no v8 it includes v8 compatible bindings to GraalVM

we are able to even build single file binarys including only the parts that are used by the JS Engine that runs Typescript.

Maybe needed short explainer

Typescript Language Implementer will be able to util the full set of Language Auditing as also all other tools like

sure also advanced users could get something out of that. but to iterate over Typescript as a language it self there is nothing better out there.

andrewbranch commented 2 years ago

@merrywhether if I understand you correctly, that’s basically what we’re hoping to do here, except instead of

These extensions could pass-through tsc unchanged

We would likely just enforce noEmit and you would point your other transpiler at your TS input. I see only negative value in enabling a pipeline of “TS (→tsc→) JS with nonsense module specifiers (→other transformer→) JS that actually runs.” If you’re going to use another transpiler or bundler, you should point it at your original TS input and get all the speed advantages that come with it. Moreover, we really don’t want to hand users a giant loaded footgun where we emit JS that we know is broken (by including .ts extensions in module specifiers). But under noEmit, I think resolving .ts extensions is totally tenable.

andrewbranch commented 2 years ago

Currently my biggest hesitation is in what to do in checking .js files.

// @Filename: a.ts
export const a: string = "hello";

// @Filename: b.js <-- ⚠️ JS, not TS!
import { a } from "./a.ts";

Lots of people check their JS with tsc --noEmit and run it directly in Node or the browser, so it sort of seems like a bad default to let --noEmit alone enable this behavior. But on the other hand, presumably every bundler is perfectly happy with this. (Also I assume this is fine in Deno?) Currently leaning towards “this is fine,” since if you’re tempted to write this in a JS file, you have a mix of JS and TS files and either

But it is a bit of a weird effect.

frank-dspeed commented 2 years ago

@andrewbranch out of bundler view in case of rollup importing "./a.ts"; is fine it will work without errors it is the same case as with every custom extension like jsx, json, css, with emit with noEmit it will emit nothing so it will not work. At last in case of rollup.

It is general ECMAScript community convention to use custom loaders in runtimes eg: Webpack is a good example of that the jscode keeps the identifier with the custom extension and the loader resolve that to a compatible format eg: JS Module export that contains transpiled what ever the original content was.

as ECMAScript Standard says:

bare string identifiers get resolved and loaded by the Host that embeddeds the ECMAScript Engine while users are able to define own module systems in userland or via Hooks in the Host that embeddeds the Engine.

Good examples of userland module systems and loaders are: systemjs, webpack-loader, stealjs A common alternativ to userland loaders is the "build pattern" where you transpile your source upfront to be run able in the desired Engine with its internal loader.

jespertheend commented 2 years ago

Lots of people check their JS with tsc --noEmit and run it directly in Node or the browser, so it sort of seems like a bad default to let --noEmit alone enable this behavior.

I'm exclusively using .js files with --noEmit with a few exceptions where I use types.d.ts or types.ts, for when writing types using JSDoc becomes too tedious. They don't contain any runtime code and are imported using JSDoc tags like

/** @type {import("./types.js").MyComplexType} */
const x = "hello";

This is not an issue when running in the browser (without a build step) because it doesn't import these files. So it doesn't matter if they are imported with .js, .ts or no extension.

This method does fail when using Deno for running tests however, since it expects types.ts to actually exist. So changing the behavior based on whether --noEmit is used would solve this. The only two issues with this approach I can see with this is that:

Conaclos commented 2 years ago
  • The language server doesn't know if --noEmit is being used. So errors will still show up in your text editor. In that sense I think an option in jsconfig.json/tsconfig.json would be preferable.

If you don't use TSC to transpile your source files, then you can use the noEMit option in tsconfig.json. The language server relies on tsconfig.json.

jespertheend commented 2 years ago

Ah cool, I thought noEmit was only available as a command line flag.

frank-dspeed commented 2 years ago

@jespertheend as rule of thumb all cli flags have a eq setting in the tsconfig

andrewbranch commented 2 years ago

I want to give an update of my investigation into this here to outline some decisions we’ll have to make.

Background

There are two separate systems involved when you write import {} from "./foo.ts": module resolution and type checking. Module resolution is what takes the input filename and the string "./foo.ts" and queries the disk to find what file you’re talking about. That result is saved and used by the type checker later. The type checker is the one that today issues the error that says you’re not allowed to use .ts in module specifiers.

Module resolution today

My assumption, until I started prototyping support for this feature, was that module resolution to .ts files given a .ts specifier was succeeding, and the type checker was subsequently issuing an error not because resolution failed, but because by the logic of our emit, the code would later fail at runtime. If this were true, it would mean we could implement this without breaking changes—we could simply stop issuing the error under the right circumstances, and everything else would Just Work™. This assumption was incorrect: because module resolution knows that .ts extensions aren’t supposed to be valid specifiers, it skips looking up a file by that exact name and (depending on the moduleResolution compiler setting) moves straight on to fallbacks. E.g., for ./foo.ts in CJS Node resolution, we will look for foo.ts.ts, foo.ts.d.ts, foo.ts/index.ts, etc., without ever looking for foo.ts itself.

In fact, this behavior is not specific to .ts files. Consider the file structure (and I’m going to use .js files here to set up an argument):

foo.txt/
├─ index.js
foo.txt
main.js

Edit: @tukusejssirs correctly pointed out that you can’t have a file and directory of the same name like this. The example I should have given was the existence of a foo.txt and foo.txt.js, which illustrates the same point and is actually possible.

If we require("./foo.txt") from main.js, what should happen? In Node, the request resolves to the foo.txt text file, and it’s assumed to be JavaScript—Node doesn’t care about file extensions. If the contents don’t parse as valid JS, it will crash. But in TypeScript, because we don’t recognize .txt as a file extension that we will read, we don’t even check if it exists; we simply move on to fallbacks and eventually resolve to foo.txt/index.js.

Why this is a problem

To be clear, I am not arguing that TypeScript should load and read files regardless of their file extension. However, I do think that it could be considered a long-standing bug of some of our existing module resolution strategies that we don’t even probe for existence of these files, since the above example shows TypeScript successfully resolving to some file, while the module resolver it intends to model resolves to a different one and probably crashes.

This behavior must change to allow resolution to .ts files. However, it is untenable to change the behavior of module resolution based on --noEmit. If --noEmit is to be the only criteria for allowing resolution to .ts files, it means module resolution must always succeed in finding .ts files by explicit extension, even if an error is issued later.

The decision

The primary decision we have to make is under what settings we fix this bug, which directly affects where .ts extension resolution can be supported.

Option 1: Fix it everywhere (with flag to opt out)

This would be a breaking change—and the kind of breaking change we really don’t like to make: one that affects the super old, super stable node module resolution mode—but, via the example I gave earlier, this is a hole in our current ability to catch actual problems, so fixing it does have some benefits aside from enabling new features.

Option 2: Add a flag specifically for allowing .ts resolution, fix it under that

Such a flag would imply/enforce --noEmit, and there is some documentation/discoverability benefit to making the feature explicit and opt-in.

Option 3: Do not treat .ts resolution as its own feature; lump it into new moduleResolution mode(s)

If we take a step back, the motivation for this feature is that there are module resolvers out there—namely bundlers and Deno—that process TS inputs and allow (or even require) them to reference each other with .ts extensions. Fundamentally, these module resolvers ought to be represented by a moduleResolution setting made for them that includes .ts resolution as well as the precise set of other features they support. The idea that you could continue to use --moduleResolution node (or any other existing mode) but turn on some additional flag and/or set --noEmit and have that change resolution is a band-aid over the fact that we don’t have full moduleResolution setting appropriate for these resolvers.

To me, reasoning through this argument makes every other approach feel like a bit of a hack. The problem with this approach, though, is it would mean we have a lot of work to do before we can ship anything, which feels very disappointing given that just a couple days ago I thought module resolution was already working in a way where we could slip this feature in with no breaks and no new flags and it would ease a lot of the pain that currently exists with using bundlers and Deno. (Specifically, the thing that needs to be decided if we go down this route is how to deal with the sheer volume of module resolvers that exist in the wild today and the fact that many of them are infinitely configurable—it’s not at all clear where to draw the line of what’s supported and what’s configurable and what’s not.)

Conclusion (sort of)

I will continue to get feedback from the team and try to push us toward some kind of consensus. I think getting our support for bundlers and other widely-used non-Node module resolvers to a usable state (especially respecting conditional exports: see recent conversation at #33079) is super important—this focus on module specifier extensions is really just a small facet of a bigger problem.

A postscript of reflection on tradeoffs, time horizons, and a personal bugbear I also hope this sheds some light on the fact that these kinds of changes can be dramatically more complex than they appear. @arendjr’s comment has been nagging at me for over a month: > This seems to be a classic defeatist argument. Just because you cannot solve _all_ problems should not be an argument to not solve _any_. I have just shown how a bug or design decision (we may never know) written the better part of a decade ago is boxing us out of being able to ship a simple fix for this issue. Instead, we have to weigh breaking changes against increasing the complexity of an already complex configuration space. There are so many things that Present TS Team would have done differently from Past TS Team. Trying to give Future TS Team bandwidth to react to unpredictable things that happen between now and then is important for a project of our time horizons. I think it’s a fair criticism to say that we’ve been very biased toward delay when weighing these concerns when it comes to modern module stuff specifically. But I want to push back against the idea that I’m just sitting on a 90% solution and not giving it to you because the remaining 10% is keeping me up at night. No. The majority of things I’ve shipped here have been compromises, heuristics, and best-effort attempts to synthesize a veneer of order over the messy confluence of systems that is the JS ecosystem. The culmination of this work will surely fit that description too. But given how interconnected all these module-related concerns are, and how fragmented the ecosystem is, it’s worth trying to get this as right as we can get it.
johnnyreilly commented 2 years ago

This is quite besides the point @andrewbranch , but I wanted to say that I've often been impressed with the articulate way you write up your thoughts and reasonings around things. It's an excellent habit that I try to emulate. Well done sir! ❤️🌻

inca commented 2 years ago

Sincerely appreciating all the arrrghumentaion above ❤️

Still I think .ts + noEmit option is even more questionable, and am frankly quite surprised that it's even on the table.

I cannot possibly come up with the arguments as strong and as accurately articulated as @andrewbranch made, and I can totally understand the amount of planes of complexity the problem resides in, as well as compatibility, future-proofing and related concerns. Almost all software I've ever seen in my life tend to evolve into something I tend to compare to overdetermined system whereby the "clean" solutions are no longer possible due to a sheer amount of constraints. So what happens is that the majority of decisions are made to be "least worst" instead of "exactly right". They further reinforce the cycle of software obsolescence (as is often depicted by "software failure curves" and similar metaphors).

This is why I think it's equally (arguably, even more than equally) important to try and not to loose the sight of the "top of the pyramid", the "why", the whole premise of TypeScript existence. The way I understand it, TypeScript's root premise (aside from ruling the World) is to improve the DX of JavaScript. I mean, if it's not about the DX, then even types make no sense (computers don't really care about the types ultimately, only humans do). And of course DX is much more than just the types: things like tsc, tsconfig.json, VSCode and friends all play a crucial role in forming this DX entity. So it's only reasonable to expect that the decisions need to be aligned with supporting the well-being of this entity, one way or another.

Now, back to .ts + noEmit. So previously we had a problem that bundlers would expect the .ts extension (because it just makes sense, the file is right here, take it) whilst tsc would expect the .js extension (because TS refuses to modify the emitted imports and it wants the emitted code to work in runtime). And now we have a whole different problem: tsc does resolve .ts but can no longer emit.

I don't know about the others, but we only use tsc to compile Node.js codebases (which still needs the JS files to be emitted, and if .ts is specified in source imports, they have to be re-written into .js) — and I can't think of any other use case from top of my head. I mean, Deno doesn't need tsc and with bundlers you don't use tsc directly. Node.js cannot run .ts, so it still needs emit, which makes .ts + noEmit combination almost completely useless. What am I missing here? What exactly did we solve? More importantly, how did the DX improve?

Once again, this is not an attempt to criticize the decision making, or to diminish the value of the amazing work you are doing. It's an attempt to see a little bit above the current horizon. JS ecosystem is already in a bad shape — don't add more to it. For example, if backwards compatibility with "super old, super stable" CommonJS is the reason why those who try writing ESM full-stop still struggle, then... Just drop the support for emitting CommonJS! Seriously. Release a major version, or put it behind a flag, name it differently — not important in the grand scheme of things. In this new mode, allow (enforce?) .ts extension in static imports an emit ESM with imports modified to .js. TSC doesn't have to know how to emit the existing non-ESM libraries — it only needs to know how to resolve the .d.ts files, so the libraries will just work in Node.js runtime. Also gives you guys an opportunity to clean up some of the other things that nobody uses (e.g. ability to emit everything into a single file).

Of course, this is just an example, but let's consider the UX part of such change.

  1. Initially makes a lot of people unhappy/angry. They expected that their codebase emitted as CommonJS will just continue to work with the major bump of TypeScript, but it doesn't.
  2. However, they read about the change in the blog and quickly realise that all they have to do is to just re-write the imports a little (add extensions and index.ts here and there). A small price to pay to remain future-proof.
  3. Those who cannot afford the change can stay on v4 until they can. Not ideal, but not the end of the World.
  4. Now the other camp, the ESM people, can finally sleep a little better at night, knowing that their backends and frontends now use the exact same import syntax and module resolution strategy, the code emitted by tsc works perfectly both in Node.js and even natively in browsers with type="module". Also they don't have to make separate editor configs and they can finally remove the hacks like ResolveTypeScriptPlugin from their huge webpack.config.jsons.
  5. Existing Node.js libraries don't have to adapt instantly, because the change affects the source code — but the output remains the same.
  6. Fewer options to keep in mind while configuring tsc. Newcomers will definitely appreciate that "stuff just works".
  7. Last but not least, it contributes an important milestone into deprecating CommonJS and Node.js module resolution. Definitely has a longer term net positive on the entire ecosystem.

Once again, I am aware that I might be over-simplifying and that the real life is more difficult. On the other hand, change the perspective a little — and everything becomes relatively simple again: you either contributing to a longer term benefits to the ecosystem by reducing the number of options, or you're contributing more complexity to deal with later on. How to see the longer term benefits? Nobody knows, I'm afraid, but time has a way of telling. 🤷‍♂️

P.S. I've bookmarked this thread with "The epic battle for a single letter change" — consider renaming the issue 😉

frank-dspeed commented 2 years ago

just my 5cent we should put it if it gets putted some where into a own resolveMode

while i would suggest to drop the whole resolve stuff in favor of classic as classic is the only mode that makes sense to engines and bundlers classic simply needs to be aware of js mjs cjs thats it every other method is doomed to cause convergences.

my standard fix for every single dependencie of any of my projects is it to transpile the original source code when authored in .ts to match the classic resolve pattern that saves me latter when i am in dev from any convergences.

finally i use a bundler for my project and bundle also the d.ts files correct as it is easy to work with them as i can always expect classic resolve to work.

i want to say that it is in genral a failure to handle diffrent stuff conditional in the whole ecosystem we should simply be forced to transpile upfront if needed and create dev bundels that is a good pattern anyway and you need to be aware of your dependencies anyway.

babel can help to migrate as it already got tooling for rewriting the pathes inside and outside node_modules to absolute pathes for the bundler that turns them into relative once when he has finished bundling.

Typescripts resolve algo should depend only on the filesystem anything like resolve is out of scope and results into this chaos

i guess this is all because in TC39 where it is already consense that the following was a mistake:

i vote for a single resolution algo per host so typescript === classic.

Jack-Works commented 2 years ago

Is it possible to do the normal TS to JS compile, but the output folder also emitting .ts as the result extension?

src/index.ts

import { x, type y } './a.ts'

Output will be

dist/index.ts

import { x } from './a.ts'
frank-dspeed commented 2 years ago

@Jack-Works that is possible but that will lead to some other edgecases depending on where the output happens not every one uses a outDir or declarationDir.

the only clean way is to handle .ts as ts files inside typescript and d.ts files most best as type module only while generating js mjs cjs and d.* files that match that.

this is also the only clean way that enables the usage of type only d.ts files as the out result in classic mode is always inside the current src dir all files do match.

even the .d.ts files that you manual created

cc: @DanielRosenwasser please consider that and also lets take the strawman proposal of typed ecmascript into account that would also be compatible this way and i am at present Implementing it in GraalJS with also additional support for .d.(m or c)ts lookup support for GraalTypescript.

so There will be a engine and a whole language implementer framework Out There that will not have multiple own implemented resolvingMethods while it allows to implement them if needed.

so Every Java Written Application will ship with a ECMAScript Implementation that allows type annotations as also supports Typescript as a Individual Language implemented based on that ECMAScript Implementation. Fully Working with the LSP even cross multiple Languages and Contexts so it will be Possible to trace that you get a Const type even cross language boundarys.

so the whole Java ecosystem is already Filesystem based. While it supports many Loader Implementations

tukusejssirs commented 2 years ago

[@andrewbranch]

foo.txt/ ├─ index.js foo.txt main.js

Just an OT note: it is not possible to create two files (or folders) with the same name in a single folder.

cefn commented 2 years ago

I accept this is tooling bug (given the current implementation) but I found Intellisense was struggling with imports from .js extensions in VSCode (I believe it wasn't able to identify any such file because it doesn't exist - the actual source file is .ts).

Needing a special case mechanism in IDEs to treat suffixed .js as mapped to .ts is another data point for having Typescript source files pointing to other actual source files, whatever the eventual structure that's needed in the transpiled code.

andrewbranch commented 2 years ago

@tukusejssirs doh, of course. The example I meant to give was foo.txt and foo.txt.js, which exhibits the same issue—we resolve "./foo.txt" to the JS file but Node would look up the actual .txt file (throwing an extension error in ESM and just interpreting it as JS in CJS).