Search Terms: import type, export {}
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 @RyanCavanaugh in https://github.com/microsoft/TypeScript/issues/41513#issuecomment-727057724_
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
This could be:
import type to not implicitly convert output to a module, (my preference because it still avoids any output JS having import without export, which caused #38696) or export {}; to _all_ modules, or //@directive we add to a file to express our intent to not output a module, orreference 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.
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.
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
My suggestion meets these guidelines:
A new
reference type ...TS-only syntax to signal that we want to use the definition but not output a module
This exists:
- import type MyModel from './MyModel';
+ type MyModel = import('./MyModel').default;
const test: MyModel = {};
test.propertyInModel = 1; // works and has intellisense
// test.propertyNotInModel = 1; throws compile error!
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 behind import type never had anything to do with keeping scripts scripty. It makes your source file a module, just like a regular import of 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 want import type; they are not useful to most people. Given that they behave exactly like import 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 with import 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 the import(...) 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 putting export {} 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-module that would enforce that all imports can be safely elided and no exports are written, and would direct the transformer not to add export {}, 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.
@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.
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.
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 to window.
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 emit export {}; in any cases (no matter how narrow) where it isn't wanted.
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.
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'}) - that worker.js won'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 postMessage and 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.
Suppose we had a directive like
// @ts-really-not-a-module
Thinking on this... far more useful (in the specific context of workers) would be a // @ts-context-worker that also changed the definition of self (and avoided common kludges like const worker = self as unknown as Worker;). That's probably a much bigger overall issue though.
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.
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 existing module or target settings.) The built-in assumption is that non-module code (scripts) _do_ share global scope, because you’re going to load them with script tags, 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.
Think like
@ts-ignore- I _shouldn't_ use it, but practically sometimes it's really useful, even if only during development.
We resisted introducing // @ts-ignore for 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.
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).
Again, TypeScript does not know anything about your workers or bundlers—people do still use scripts with shared global scope.
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.
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.
@andrewbranch I think this might be how we're using TS, which could be completely wrong...
... your tsconfig says, implicitly or explicitly, that it is targeting an es2015+ module system ...
...
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"].
This is fine if my entire project is exclusively DOM (with ES module import) or my entire project is exclusively workers (with importScripts). If it isn't I need multiple tsconfig.json files 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.json files and me spending more time messing about with config than actually writing code.
TypeScript does not know anything about your workers or bundlers
_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.
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.
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.
I've been a lurker in this issue and its previous iterations, but from what I understand, wouldn't https://github.com/microsoft/TypeScript/issues/13135 solve the entire problem here?
Suppose you could do
type { T1, T2, T3 } = typeof import("./other-file");
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.
@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.
The more I think about this, the more I believe it’s not a problem. @KeithHenry, you needed a way to use module types in non-module files, which exists. You replied
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.
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=none combined with --target=es2015 or 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=none to enforce that you only use module code _that can be elided from emit_: basically, import type or any imports that would be allowed to be written as import 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.
So this leads to lots and lots of tsconfig.json files and me spending more time messing about with config than actually writing code.
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.
I tried --module=none and it does export commonjs instead of none. That would be great if it worked. I would be willing to have multiple tsconfig files and have a naming convention for my files like my-file.webworker.ts that I would use an include setting on, like so ["src/**/*.webworker.ts"].
In my use case I have some files that are specifically for the DOM (iifes), some that are modules and some that are pseudo-modules that I created for myself to work with my service worker until we get module support in service workers.
@andrewbranch I really like this idea! module=none removing all import and export from the emitted JS makes loads more sense than treating CommonJS as default. It always seemed weird to me that specifying module=none would create JS that wouldn't run in any browsers, it really should output vanilla JS.
Most helpful comment
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.