TypeScript: importsNotUsedAsValues: preserve is not preserving
Bug Report
My interpretation of the "importsNotUsedAsValues": preserve option is that the goal is to emit value imports as-is. So that you can rely on the JS emit reflecting the JS you write. Here is a case that seems to violate that.
š Search Terms
importsNotUsedAsValues preserve import binding missing skipped omitted import for side-effects JS+types
š Version & Regression Information
- This is the behavior in every version I tried
⯠Playground Link
Playground link with relevant code
š» Code
// @importsNotUsedAsValues: preserve
// @filename: child.ts
export default 1
// @filename: main.ts
import d from './child'
eval("d");
š Actual behavior
JS emit for main.ts is missing the import binding.
import './child';
eval("d");
š Expected behavior
The value import should be preserved.
import d from './child';
eval("d");
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 4
- Comments: 16 (6 by maintainers)
So this same thing came up in the context of Svelteās transpilation processāthey had to write a custom transformer to re-add back imports that were removed because they look unused, but were in fact used in template code, which TS doesnāt understand. Moving some conversation from #43687 and continuing it here.
In #43687 I proposed a mode where:
because
This all makes sense. Do you think we must find a way to support the concise type-only default import in combination with value imports?
It feels kinda unnecessary to me given we can already do (non-concise) rebinding which makes it all unambiguous.
One question I had, which may be difficult to answer, is whether people who check with TypeScript but transpile with Babel or something else actually set
modulein their tsconfig.json at all. They might just setnoEmitand then ignore any emit-related settings. I briefly considered allowing es2015+ ornoEmit, but found that we didnāt have similar heuristics anywhere else in the compiler. I ultimately decided that anyone in that boat should just go ahead and setmodule: esnextin their tsconfig, as it does affect the type/grammar checking of a few other things, so leaving it off is really a misconfiguration.Thatās a step too far away from standard ES syntax. We were comfortable with a
typeprefix in part because Flow did it first, so TC39 would be unlikely to innovate in a way that conflicts. But in general we avoid messing with expression-level syntax.Ok, I think thatās fair.
I was hoping this feature would achieve preservation of the original JS code, i.e. avoiding dead identifier elimination. But I see it does not have that ambition so this is not a bug and I will close it. And having written all this up, Iām now thinking that was an unreasonable assumption on my part š
As background, this issue was detected because we ban the
import "thing"form (to discourage relying on side-effects) in a build rule that operates after TS->JS conversion - and that was erroring despite the source code not using that form - but I view that as an āusā issue and not convincing general argument. I was also trying to test some name-shadowing behavior by disabling the type-checker but was thwarted - again this is not a practical use-case.Answering your question, I think stringified execution is the only practical case. In this case, it seems there is no way to get TypeScript to emit the legitimate input JS code even if you disable the checker. So instead, when you detect in your tests or runtime execution that the code is faulty, the workaround is to add a redundant usage site to prevent the compiler from eliminating it.
IMO this is a super-mild case of breaking the JS+Types model because we canāt passthrough plain JS. Itās an unavoidable transform. Maybe itās also a category mismatch because TypeScript is kinda doing the work of a minifier. Other simplistic forms of dead code, such as unused
vardeclarations, are not eliminated. I appreciate that eliminating type names from the import statements is essential core functionality, and that ānot statically detectable as a used valueā is a neat heuristic/approximation for achieving that. Fixing that whilst preserving isolated 1:1 per-file transpilation seems to require more thought. I think this is worth thinking about for the future but itās not an urgent matter.I canāt give a general answer, but in my specific case, in which there is no down-levelling because we use a modern engine, we always use
module: esnext.This is used in both the build toolsā main API usage and also in the tsconfig we write to disk to drive VSCode which contains
noEmit.Requiring
module >= ES2015to use the newpreserve-all-the-thingsmode seems reasonable.The primary use-case here is to minimize source transforms and just erase the type syntax, to cater for code/tools whose runtime behavior is broken by todays dead-identifier transform. I foresee it being used most commonly in toolchains that also use
target: ESnext.Thereās a quote from Paul Rudd in Forgetting Sarah Marshall that I think applies here: āthe less you do, the more you doā
The stylistic/lint use-case is new to me. Personally I feel similar to @dummdidumm and would prioritize functional needs over aesthetics. Implementing this for CommonJs, whilst feasible, feels like unnecessary work to me (and maybe goes against the Paul Rudd principle of ādo lessā?). If the demand for this feature with non-ESM module targets arises in future, maybe the requesters could create an ESLint rule as @milahu suggests.
Iāve been thinking about this new mode, which will probably be a new value for
--importsNotUsedAsValues. I was thinking of a name likepreserve-exactorpreserve-all. One question this brings up is whether it should even be allowed with--moduletargets lower thanes2015. It certainly becomes a misnomer to say āpreserve-exactmy importsā if weāre also being told to transform them into a completely different module system. More to the point, in non-ESM module targets, all imports are essentially ānamespaceā imports, and individual import specifiers are referenced with property access within the code. To take @robpalmeās original example:Suppose you compiled this with
tsc --importsNotUsedAsValues preserve-exact --module commonjs. Presumably, you would getbecause any actual trackable usage of
dwould becomechild_1.default. So the output you get here is different from what you could get with any other set of options that exists today, but itās not actually useful unless you knew to expectchild_1in the emit, which we would not encourage.On the other hand, ever since type-only imports were added, weāve had a consistent stream of people who are frustrated that they canāt opt into a mode where all types must use a type-only import and never be combined with value imports, which is what weāre now thinking of offeringābut as far as I could tell, most of the people who have previously asked for that have been motivated by, well, what I consider to be linty pedantry, not because of any sort of runtime constraint. If we were to require es2015+ for this flag, it would probably rain on the parades of some people who want this separation for purely stylistic/DX reasons.
preserve-exactwith--module=commonjsorsystemoramdwould not exactly be harmful, but the emit changes it would supposedly produce would be useless and hard to explain. (And skipping emit changes for those module targets would be similarly hard to explain.) Iām leaning toward requiring es2015+. Thoughts?Thank you for reviving the issue @andrewbranch. I see how Svelte needs this same ājust remove the typesā mode. I like where this is going.
The mode you proposed above sounds reasonable and will solve the problem. It means the whole import statement is either retained or eliminated based on whether itās a type-only import or not.
There is a potential extension to the above mode. Which is to introduce new syntax for explicit type-only identifiers. I read this is already supported by Flow and Hegel but cannot find supporting documentation.
I think we would still need your new mode in order to error in the case where
aorchave no value export in"module". The benefit of explicit type-only identifiers is ergonomics. It helps keep the code DRY. Otherwise some people may be disappointed to find they must write two independent imports to pull in a value and a type from a module.Please say if you think this extension is better handled in a separate issue - I would be happy to create it.
Thank you for reconsidering this @andrewbranch ! What you wrote is exactly what would be needed. An illustrative example:
The preprocessor invokes
ts.transpileModulewith only the contents of the script tag, which would beGiven the current mechanics of TS transpilation, both
valueOnlyUsedInTemplateandaTypewould be removed because they are not used as a value. In case ofvalueOnlyUsedInTemplatethatās wrong. To work around this, the TS Svelte preprocessor adds a transformation which ensures every value imported throughimportis preserved - but this is also true for interface imports. As a consequence the developer has to tediously separate type and value imports, because TS will merge type and value imports as soon as one value import is given for the same import location. It therefore would be great to reconsider this feature request which would meanimportsNotUsedAsValueswith strict separation of type and value importsI think tackling this is closely related to the intention in #39432 which I left a comment on just now.
Hmm, it was an intentional decision that
--importsNotUsedAsValues=preservewouldnāt change the behavior of eliding the actual imported names; it would just keep the module dependency graph as you wrote it. Are there any use cases for this besides stringified execution?