Repository navigation
Add compiler option to allow module types to be checked in non module output files #41567
Description
Search Terms: import type, export {}
Suggestion
In #41513 and #41562 projects are broken by a design change to always include export {}; in the JS output if import or import type is in the TS source, regardless of whether it is in the JS output. This is a breaking change for a lot of developers and painful to fix (and by fix I mean practically get the output of our TS project to run in browsers, not necessarily adhere to ECMA spec compliance and best possible practice).
However, export {}; considered desirable:
You're free to suggest a new compiler option but this is the intentional behavior.
Originally posted by Ryan Cavanaugh (@RyanCavanaugh) in #41513 (comment)
Please could we have a new compiler option to not force all files that in any way use ES modules to always have to include export {}; in the JS output.
I'm not sure of the best way for this to be applied, but the goal would be
- Type checking compatible with an ESM project.
- Output compatible with the current state of module support in browsers.
This could be:
- A compiler option that causes
import typeto not implicitly convert output to a module, (my preference because it still avoids any output JS havingimportwithoutexport, which caused Consider emittingexport {}in all ambiguous module output #38696) or - A compiler option to not add
export {};to all modules, or - A
//@directivewe add to a file to express our intent to not output a module, or - A new
reference type ...TS-only syntax to signal that we want to use the definition but not output a module
Whatever is easiest to code and causes least friction/confusion for the community.
Use Cases
I have a large TS project that uses numerous web workers, a service worker and web components that load side effects.
In the first two cases including export {}; breaks the resulting JS, as these workers are not modules and are not intended to produce module loaded JS output.
In the case of side effect web components no export is expected (they use customElements.define) so it's just wasted bytes, but it doesn't break anything. Across a project with a lot of components the many export {}; that will never be referenced by anything adds up.
In addition during migration between different reference types it may be extremely beneficial to not strictly enforce adherence to one type or the other, at least while not in production. Any modules = all must be modules effectively makes this migration harder, even if it is a sound best practice.
Examples
I have a model MyModel.d.ts.
In worker.ts I want TS to check my code against this model:
import type MyModel from './MyModel';
const test: MyModel = {};
test.propertyInModel = 1; // works and has intellisense
// test.propertyNotInModel = 1; throws compile error!I want to use the JS output this with a worker in another file:
const worker = new Worker('path/worker.js');This worked in 3.8, but fails in 4.0 due to #41562
Checklist
My suggestion meets these guidelines:
- This wouldn't be a breaking change in existing TypeScript/JavaScript code (it would fix a lot of projects broken by 4.0)
- This wouldn't change the runtime behaviour of existing JavaScript code (it would be opt-in)
- This could be implemented without emitting different JS based on the types of the expressions
- This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.) (we're removing additional content that wasn't in 3.8)
- This feature would agree with the rest of TypeScript's Design Goals. (particularly 4, 7 and 11)
Activity
andrewbranch commentedon Nov 17, 2020
This exists:
There are a couple problems here. The first is a misconception about the purpose of
import type, which is not necessarily represented in this issue, but I’ve seen it in related issues so I want to remove it from any present confusion. The motivation behindimport typenever had anything to do with keeping scripts scripty. It makes your source file a module, just like a regularimportof a type/interface that also gets elided. I’ve seen a lot of people react to this news with comments like “Wait, so why would I ever use that then‽” which is the correct response. A select few groups of users had good reasons to wantimport type; they are not useful to most people. Given that they behave exactly likeimport SomeInterface from "./whatever", we can completely ignore type-only imports in this discussion.So the question becomes, how do I reference types from a module in a script? And the answer is, and has always been, the
import('...')type syntax. That didn’t change withimport type. You might say in response, “I don’t like using that syntax; it’s verbose and unwieldy and I am left choosing between the two bad options of (1) repeating theimport(...)syntax everywhere I need it and (2) writing a type alias (as in the above example) that becomes globally visible, due to the file’s scripty nature.” That’s a reasonable argument outlining a problem that I think is well worth solving. You might also say “Look, what I want is very simple; it literally used to work this way before #38712; just give me some way to write imports in a file that I know will be elided without puttingexport {}in my JS, and everything will work fine.” I want to explore the problems with this argument, because it does sound reasonable at first glance.The problem is determining the scope of top-level declarations in the file. Suppose we had a directive like
// @ts-really-not-a-modulethat would enforce that all imports can be safely elided and no exports are written, and would direct the transformer not to addexport {}, guaranteeing that the JS emit would be script-friendly. This would be easy to implement and would totally satisfy your request. The problem is that the scope of that file’s declarations is really unclear. In your case, you’re loading it into a web worker, which has its own scope, so using the same scope rules that we use for modules makes sense. However, someone could just as well load that JS into a<script src="...">, where its top-level scope is shared with other scripts. In this case, we would fail both in letting you legitimately share top-level variables across scripts as well as in issuing duplicate declaration errors when you accidentally introduce the same name twice across different scripts that share the same scope. More pragmatically, there is a lot of compiler code that assumes that any import/export declaration does in fact make that file a module, so I suspect any feature that would let files import types but be treated like scripts would be a difficult change.This is why I’m skeptical of a directive or compiler option that would “simply” let you use imports in a file but emit a script. Maybe there’s something we could do for Web Workers specifically. But I would like to understand how bad of a solution the
import('...')type syntax really is.KeithHenry commentedon Nov 18, 2020
Andrew Branch (@andrewbranch) Cheers, that's a good workaround for #41562 and I've closed it.
As an alternate syntax workaround that works, thanks for letting me know I can import types like that, though I still think it's somewhat confusing and that there is a valid use case for a compiler option/directive.
Who would do that? I'm not writing an API or public library. I think this directive would be a very bad idea in the situation you describe, but this is a web worker - it's going to fail if you load it with
<script src="...">anyway because it was written as a background worker and is doing things that aren't available towindow.This wouldn't be for common practice, this would be so that in the corner case where we knew the output JS would be loaded in a certain way we could ensure that TS knows not to break that. Think like
@ts-ignore- I shouldn't use it, but practically sometimes it's really useful, even if only during development.The problem isn't that TS forces
export {};most of the time, the problem is that there is no way to not emitexport {};in any cases (no matter how narrow) where it isn't wanted.It's top level scope can't be shared with other scripts, in a worker it's a whole new entry point (from the point of view of bundlers).
Take the case where we can use
const worker = new Worker('path/worker.js', { type: 'module'})- thatworker.jswon't have duplicate declaration errors, quite the opposite: anything we want to use in the worker we need to declare again. It needs its own code splitting (incidentally this is something bundlers are often bad at).A very common practice with web workers is passing data back and forth - the main JS passes an entity across using
postMessageand the web worker does something time consuming with it asynchronously. This means the workers tend to be very focused (i.e. typically don't want to import all the code used by the main library) but do need to reference the same record/interface type definitions a great deal.Thinking on this... far more useful (in the specific context of workers) would be a
// @ts-context-workerthat also changed the definition ofself(and avoided common kludges likeconst worker = self as unknown as Worker;). That's probably a much bigger overall issue though.andrewbranch commentedon Nov 18, 2020
My point is that there is literally nowhere you’re telling TypeScript that this file is exclusively targeted for a web worker. (In fact, if you’re getting
export {}in it, that means it is part of a program where your tsconfig says, implicitly or explicitly, that it is targeting an es2015+ module system, which is not true for that file. You could definitely look at this whole problem as a configuration error—you are compiling this file with incorrect settings. That said, it’s kind of tedious to split into its own program, and would still emit module code with any of the existingmoduleortargetsettings.) The built-in assumption is that non-module code (scripts) do share global scope, because you’re going to load them withscripttags, because when TypeScript was invented, both bundlers and Web Workers were in their infancy, and that’s literally how people got JavaScript onto their sites. So while we have already established that certain things would coincidentally solve your problem because you are using web workers, I’m explaining that they cannot be generalized to all non-module code.We resisted introducing
// @ts-ignorefor a long time! I’m glad it exists, but it’s not exactly an ideological blueprint for new features. We’re interested in actually solving these problems, not just silencing them.Again, TypeScript does not know anything about your workers or bundlers—people do still use scripts with shared global scope.
This is already what you would get if you compiled your web worker files with a tsconfig that had
"lib": ["webworker"]instead of"lib": ["dom"]. But, this has no effect on the module system.KeithHenry commentedon Nov 19, 2020
Andrew Branch (@andrewbranch) I think this might be how we're using TS, which could be completely wrong...
This is fine if my entire project is exclusively DOM (with ES module
import) or my entire project is exclusively workers (withimportScripts). If it isn't I need multipletsconfig.jsonfiles and rules for where to apply them.In practice I'm using workers as kind of background threads - something is slow in the UI and causes jank/dropped frames, so I split it out into a worker and call it with Comlink (or something similar). In a large web application I have many files that need to be treated as web workers, arranged near the code that calls them and that they work closely with.
So this leads to lots and lots of
tsconfig.jsonfiles and me spending more time messing about with config than actually writing code.Exactly! It doesn't know so it assumes. Now that assumption may be best practice and it may be the right call 99% of the time, but there's no way to opt out of it in the times when it isn't.
I agree again. I hate using it and every time is technical debt I know needs fixing.
But it's still a really useful thing to have in your toolbox.
AlCalzone commentedon Nov 19, 2020
I've been a lurker in this issue and its previous iterations, but from what I understand, wouldn't #13135 solve the entire problem here?
Suppose you could do
this should preserve the non-moduleness of the file but give you a relatively elegant way to re-use many types from other files without importing them.
KeithHenry commentedon Nov 19, 2020
AlCalzone #13135 would be an alternate fix to #41562 and would be one possible solution here.
However, give that we already have
import type { T1, T2, T3 } from "./other-file"(and that kind of means #13135 would now be a corner case and unlikely to get added) I'd rather be able to consistently use that than have different syntaxes for different parts of the same project.andrewbranch commentedon Nov 19, 2020
The more I think about this, the more I believe it’s not a problem. Keith Henry (@KeithHenry), you needed a way to use module types in non-module files, which exists. You replied
But I never heard a case for why there is a valid use case for anything further, beyond “I still think it’s somewhat confusing.” The intricacies of module systems is definitely one of the most confusing parts of TypeScript, but even if it’s unintuitive, there are rules and they are working as intended. That means the solution I gave you is not just “an alternative syntax workaround,” it is a different tool with different semantics, and if you’re aware of the rules around modules and the fact that ImportTypeNodes exist, you can put the pieces together and come to that solution. We might have a docs/learning problem here, but that doesn’t mean we need a new compiler option that would further complicate the already confusing module rules.
The other tool that I would propose we can make useful here is fixing
--module=none. Currently, it has what I believe to be a bug, where--module=nonecombined with--target=es2015or higher (1) does not issue any errors on using imports or exports, and (2) downlevels that module code to CommonJS. This, I believe, is completely incoherent, and has probably not been fixed only because nobody uses--module=none. I think we could justifiably revise the logic here, and use--module=noneto enforce that you only use module code that can be elided from emit: basically,import typeor any imports that would be allowed to be written asimport type, as well as type-only exports. Then, of course, we emit no module marker to the JS under these settings. This would be the appropriate setting to compile all web workers under.I suspect you could work this out so that all workers are compiled under one tsconfig and all browser code is compiled under another, without too much fuss later. I’m somewhat sympathetic to the idea that setting up the correct config for web workers is tedious, but we’re currently talking about an issue of capability and correctness.
jon49 commentedon Nov 21, 2020
I tried
--module=noneand it does exportcommonjsinstead ofnone. That would be great if it worked. I would be willing to have multipletsconfigfiles and have a naming convention for my files likemy-file.webworker.tsthat I would use anincludesetting on, like so["src/**/*.webworker.ts"].In my use case I have some files that are specifically for the
DOM(iifes), some that aremodulesand some that are pseudo-modules that I created for myself to work with my service worker until we get module support in service workers.KeithHenry commentedon Nov 23, 2020
Andrew Branch (@andrewbranch) I really like this idea!
module=noneremoving allimportandexportfrom the emitted JS makes loads more sense than treating CommonJS as default. It always seemed weird to me that specifyingmodule=nonewould create JS that wouldn't run in any browsers, it really should output vanilla JS.blixt commentedon Dec 30, 2020
Does this mean that it's possible today? We had a TypeScript project with Service Worker source code and as of TypeScript 4.x I see no way or even hack to make it possible to leverage TS types while developing while also outputting valid code for a service worker.
This is what we used previously, until
export {}started being injected into the build output:The above does still make TypeScript use the correct types during development, but there appears to be no way to get it to compile correctly.
Because this came as a bit of a surprise during a huge upgrade to TypeScript 4.1, we had to quickly remove all TypeScript compilation from our service worker codebase (luckily small), so now it's just plain JavaScript with JSDoc for typing.
The alternative solution for others running into this (assuming there's not already something better) might be to have a script remove the
export {}line from the built service worker script.The hack above comes from this issue: #14877
andrewbranch commentedon Dec 30, 2020
Blixt (@blixt) it sounds like you would benefit from my
--module=noneproposal. As of today, you’d have to do some more intrusive workarounds to get what you want. If you’re currently importing types into web worker scripts, you’d have to move/copy those types to globals or import them with theconst foo: import("./mod").Foosyntax.jon49 commentedon Nov 24, 2021
I don't know what was decided to do with this. But according to this issue 16577:
And another:
So, if we are to believe those comments then the
export {}value should not be created during compilation. If someone needsexport {}in their code after TypeScript processing they should add it themselves. This breaks the behavior of the TypeScript that is written and is surprising to the user. The browser understands that a file is a module with no regard to havingexport {}there or not as that is defined elsewhere (in thescripttag).So, considering what was said on other threads about not changing the JavaScript. This change in TypeScript breaks it promise that we can just write the JS the way it should be at runtime, and tell TS how to interpret that.
kbalint commentedon Jan 5, 2022
My case is the following:
porting some .net fw app to .net core, and I need to rewrite the SignalR part. The .net fw version had a proper index.d.ts out of the box, but with the dotnet core version it is said, that the TS code is 'self-documenting' if imported so no index.d.ts is created by the people on MS payroll.
well if you include the signalr.js client (npm) and want to use SignalR as a type in a TS code all you get is a type error.
what can you do? you try to generate a type definition for yourself from the JS code according to this tutorial
All it will output is:
export const signalR: {};by running circles, I've checked out the TS code from github and from npm, and tried the same generation on the source files directly. That was OK, I got the .d.ts files I needed for the whole 50 files or so.
To finally start working on the feature I've done this:
export {};appeared at the end, that's why I'm here.and it is not working:
TS2339 (TS) Property 'start' does not exist on type 'typeof HubConnection'it's the same for the generated d.ts file and the source .ts file. So as I see, I have the following options:
A) I go down some unknown road to use the import / export in production that I do not need at all, because I'm forced to do it
B) write some quick C# module to eliminate
export {};in the build pipelineC) go with
await (this._connection as any).start();...why am I even bothering?I think it is worrying that such simple solutions (try out a feature with the right tooling) is intentionally made overcomplicated by the authors themselves. It's working against being the developer experience inclusive, from my point of view it's getting harder and harder to keep up with this overengineered stuff, even for experienced devs, let alone beginners..
3 remaining items
andrewbranch commentedon Jun 3, 2022
Actually, it seems like this was discussed at #47724 and the conclusion was “Ugh.”
haydentech commentedon Jun 24, 2022
Using type-only imports in a file with global script scope. :-) It's not the end of the world. I'll keep removing the export via sed and move on with my life.
andrewbranch commentedon Jun 24, 2022
I don’t understand. If you just use the syntax
type Whatever = import("./foo").Whatever, we analyze the file as a global script. This meansvs. what you’re doing now, where
What am I missing here? How is the latter scenario better?
jon49 commentedon Jun 25, 2022
If I recall correctly, it has been a while. Is that
type Whatever = import("./foo").Whateverimported in more than one file causes issues. TypeScript doesn't like it. So, you are stuck doing it in only 1 file in order to not break your code base. At that point it is pretty useless though.ValeTheVioletMote commentedon Aug 2, 2022
Just wanted to throw my 2c in that this is also ruining our enterprise project for the same reasons. Stuff like this is why in future projects I went back to normal JS and only incorporating types with JSDoc comments. It's wordy but you don't have to deal with your entire production pipeline screeching to a halt because a TS update broke perfectly valid TS code. JS will always be properly supported, but not TS, React, etc etc.
All sorts of projects are ruined by opinionated decision-makers who think they know what their customers want and correcting for them instead of trusting our intelligence... TS should be a 'dumb' tool that does the job it's set out to do and nothing more, not a smart one that's trying to 'figure out' what we want for us.
Additionally, there's issues with the proposed import("") workaround.
This works:
But this does not:
Sure:
SachinShekhar commentedon Aug 10, 2022
No need to add an extra compiler option. There’s already a
module=noneoption which defaults toCommonJS. TypeScript should use this option to not include any import/export.KeithHenry commentedon Aug 17, 2022
We want
importdirectives in the output. We don't always wantexportbecause the output file is sometimes a web component (which registers as a side effect) or a worker/service worker (both of which can import modules but crash if trying to export anything).TS changed to always force an
export, I think they just assumed either nobody used workers or everyone that did had separate tsconfig files for each one.Vardner commentedon Oct 10, 2022
Soon it will be 2 year anniversary since devs break a lot of working code with just 1 update. For 2 years they couldn't come up with any trade-off to solve our problem, well-done Microsoft 🤪
ValeTheVioletMote commentedon Oct 10, 2022
this critical issue is among many examples why "TS is just JS but better!" or "TS is a superset of JS" is simply untrue.
I have since moved back to JS and now use JSDoc comments to leverage types where I want them, only where I want them.
jon49 commentedon Oct 11, 2022
At this point I just use
ESBuildto compile myTypeScriptcode as it doesn't give that issue and is flexible enough for my needs. But I also leverage just simpleJSDocfor parts of my code base also.Yeah, it was an unfortunate decision which was made which broke the assurances they made to the users of
TypeScript. I could see making the same mistake too. But I think a mature organization would admit it was a mistake or say they are no longer going to keep the original promise not to add code to your code base.This change was based off of a tooling problem with a different compilation tool so it seems like a weird decision to me. I still don't understand why they made that decision. But at the same time, I've made plenty of mistakes in my coding life, but I try to own my mistakes though.
change test commands & config to use tsx/ESBuild packages for compila…
change test commands & config to use tsx/ESBuild packages for compila…
Squashed commit of the following:
Squashed commit of the following:
change test commands & config to use tsx/ESBuild packages for compila…