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)

Most upvoted comments

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:

  1. import type declarations are removed (as they are now)
  2. import (without type) declarations are not touched whatsoever, regardless of usage—that is, imported names are not removed.
  3. It is an error to import anything that is only a type without using import type. (Contrast to --importsNotUsedAsValues=error, which allows pure types to be imported without import type as long as the same import declaration also imports at least one value.)

because

  1. That transformer could be removed.
  2. You would no longer need to be careful about combining type and value imports, because it would be enforced that you don’t do it.

That seems much better than the status quo, because it would improve transpilation performance and guard against a pitfall that seems very easy to fall into.

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.

import { type default as foo, Baz } from './mod'

I foresee it being used most commonly in toolchains that also use target: ESnext.

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 module in their tsconfig.json at all. They might just set noEmit and then ignore any emit-related settings. I briefly considered allowing es2015+ or noEmit, 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 set module: esnext in 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 type prefix 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 var declarations, 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 >= ES2015 to use the new preserve-all-the-things mode 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ā€

do-less

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 like preserve-exact or preserve-all. One question this brings up is whether it should even be allowed with --module targets lower than es2015. It certainly becomes a misnomer to say ā€œpreserve-exact my 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:

import d from './child';
eval("d");

Suppose you compiled this with tsc --importsNotUsedAsValues preserve-exact --module commonjs. Presumably, you would get

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });

const child_1 = __importDefault(require("./child"));
eval("d");

because any actual trackable usage of d would become child_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 expect child_1 in 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-exact with --module=commonjs or system or amd would 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.

import { a, type b, c, type d } from "module";

I think we would still need your new mode in order to error in the case where a or c have 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:

<script lang="ts">
   import { value, valueOnlyUsedInTemplate } from './somewhere';
   import type { aType } from './somewhere';

   const foo: aType = value;
</script>
{foo} {valueOnlyUsedInTemplate}

The preprocessor invokes ts.transpileModule with only the contents of the script tag, which would be

   import { value, valueOnlyUsedInTemplate } from './somewhere';
   import type { aType } from './somewhere';

   const foo: aType = value;

Given the current mechanics of TS transpilation, both valueOnlyUsedInTemplate and aType would be removed because they are not used as a value. In case of valueOnlyUsedInTemplate that’s wrong. To work around this, the TS Svelte preprocessor adds a transformation which ensures every value imported through import is 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 mean

  • a new variant of importsNotUsedAsValues with strict separation of type and value imports
  • corresponding TS error if type and value import are mixed
  • corresponding autocompletion intellisense which keeps imports separate and switches an import from type to value if the semantics change

I 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=preserve wouldn’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?