Repository navigation
Proposal: deprecate importsNotUsedAsValues and preserveValueImports in favor of single flag #51479
Description
Background: importsNotUsedAsValues
importsNotUsedAsValues was introduced alongside type-only imports in #35200 as a way to control import elision. In particular, Angular users often experienced runtime errors due to the unintended import elision in files like:
import { MyService } from './MyService';
class MyComponent {
constructor(private myService: MyService) { }
}It appears to TypeScript as if the import declaration can be elided from the JS emit, but the ./MyService module contained order-sensitive side effects. By setting the new --importsNotUsedAsValues flag to preserve, import declarations would not be elided, and the module loading order and side effects could be preserved. Type-only imports could then be used to elide specific import declarations.
Background: preserveValueImports
preserveValueImports was added in #44619 as a way to control elision of individual imported names so that symbols can be referenced from places TypeScript cannot analyze, like eval statements or Vue templates:
import { doSomething } from "./module";
eval("doSomething()");Under default compiler options, the entire import statement is removed, so the eval’d code fails. Under --importsNotUsedAsValues preserve, the import declaration is preserved as import "./module" since the flag is only concerned with module loading order and potential side effects that may be contained in "./module". Under the new --preserveValueImports option, doSomething would be preserved even though the compiler thinks it is unused.
In the same release, the ability to mark individual import specifiers as type-only was added as a complement to --preserveValueImports.
User feedback
These two flags, along with type-only import syntax, were designed to solve fairly niche problems. Early on, I encouraged users not to use type-only imports unless they were facing one of those problems. But as soon as they were available, and consistently since then, we have seen enthusiasm for adopting type-only imports everywhere possible as an explicit marker of what imports will survive compilation to JS. But since the flags were not designed to support that kind of usage of type-only imports, the enthusiasm has been accompanied by confusion around the configuration space and frustration that auto-imports, error checking, and emit don’t align with users’ mental model of type-only imports.
Further, because the two flags were designed at different times to address different issues, they interact with each other (and with isolatedModules) in ways that are difficult to explain without diving into the background of each flag and the narrow problems they were intended to solve. And the flag names do nothing to clear up this confusion.
Proposal
We can solve the problems addressed by importsNotUsedAsValues and preserveValueImports with a single flag that is
- easier to explain
- less complex to implement
- well-suited to users who want to use type-only imports for stylistic reasons
On the schedule of #51000, I propose deprecating importsNotUsedAsValues and preserveValueImports, and replacing them with a single flag called (bikesheddable) verbatimModuleSyntax. The effect of verbatimModuleSyntax can be described very simply:
verbatimModuleSyntax: Emits imports and exports to JS outputs exactly as written in input files, minus anything marked as type-only. Includes checks to ensure the resulting output will be valid.
No elision without type
This is a stricter setting than either importsNotUsedAsValues or preserveValueImports (though it’s approximately what you get by combining both with isolatedModules), because it requires that all types be marked as type-only. For example:
import { writeFile, WriteFileOptions } from "fs";would be an error in --verbatimModuleSyntax because WriteFileOptions is only a type, so would be a runtime error if emitted to JS. This import would have to be written
import { writeFile, type WriteFileOptions } from "fs";No transformations between module systems
True to its name, verbatimModuleSyntax has another consequence: ESM syntax cannot be used in files that will emit CommonJS syntax. For example:
import { writeFile } from "fs";This import is legal under --module esnext, but an error in --module commonjs. (In node16 and nodenext, it depends on the file extension and/or the package.json "type" field.) If the file is determined to be a CommonJS module at emit by any of these settings, it must be written as
import fs = require("fs");instead. Many users have the impression that this syntax is legacy or deprecated, but that’s not the case. It accurately reflects that the output will use a require statement, instead of obscuring the output behind layers of transformations and interop helpers. I think using this syntax is particularly valuable in .cts files under --module nodenext, because in Node’s module system, imports and requires have markedly different semantics, and actually writing out require helps you understand when and why you can’t require an ES module—it’s easier to lose track of this when your require is disguised as an ESM import in the source file.
Activity
robpalme commentedon Nov 12, 2022
This is great. Simplifying the configuration to achieve the more explicit/understandable mental model will really help adoption.
As a thought exercise, I wonder if we can constrain the configuration even more to reduce combinatorial
tsconfigcomplexity. Does this new flag need to be independent ofisolatedModules? Would it ever make sense to use the option withoutisolatedModules?Concretely, could we express the new option as
"isolatedModules": "strict"?andrewbranch commentedon Nov 16, 2022
Yeah, I think the flag as I’ve described it is a superset of
isolatedModules, with the lone exception of a rule against accessing ambient const enums inisolatedModules. The const enum interaction with other flags has gotten incredibly messy and it’s not clear to me what’s needed there. But making this behavior a variant ofisolatedModulesseems doable. My only regret is thatisolatedModulesis itself not a very descriptive name, but I can live with that.andrewbranch commentedon Dec 2, 2022
We agreed to move forward with this.
verbatimModuleSyntax, just because it’s more understandable thanisolatedModules. It will imply/supersedeisolatesModules.--module commonjs(I think Jake Bailey (@jakebailey) has investigated this a bit, but I don’t recall the conclusion)import typebut are not (the functionality we lose from--importsNotUsedAsValues error)robpalme commentedon Dec 3, 2022
I'm very happy this is going ahead.
Given the new flag now obsoletes three existing flags, could we reduce config complexity by erroring if any of the existing flags are used in conjunction with the new flag regardless of their value? Meaning this would force users to clean up and remove those old entries in
tsconfigand would shortcut any discussion of how they interact.andrewbranch commentedon Dec 5, 2022
Yeah, I think that’s reasonable.
jakebailey commentedon Dec 6, 2022
My memory is bad, but given I was able to convert the entire codebase to pure ESM with esnext (as part of early module conversion perf testing), I think we could change this, yes, if we continue to use a bundler. Once there's a PR it should be straightforward to test, I think. But, I don't quite know what will happen to our non-bundler case, because that definitely needs
--module commonjs.andrewbranch commentedon Dec 6, 2022
Well, if we have multiple configs for multiple compilations, the one that has
--module commonjscan simply not enableverbatimModuleSyntax. But ironically, the purported value of the flag is emit transparency, which is exactly what we don’t have by using a bundler, and what we might think valuable in our Node build 🙃Jack-Works commentedon Dec 10, 2022
as a non-native, this is the first time I see the word "verbatim" so it's not so understandable to me 😂
9 remaining items
andrewbranch commentedon Jan 27, 2023
Also, it doesn’t really matter for what people care about here, but the blog is currently incorrect.
import {type T} from 'mod'becomesimport {} from 'mod', notimport 'mod'. I clarify this just to reinforce the point that the syntactic transformation is dead-simple, and does what it looks like it would do, in my opinion.bradzacher commentedon Jan 27, 2023
Reading the OP and the thread I didn't see any mention of this behaviour and given that the base behaviour of TS is to not do this, I didn't know that
importsNotUsedAsValuesworked this way (I've never used the flag, personally). Which is why I said the lint rules would cover things.consistent-type-importswill enforce that any import specifier used only in a type location must be marked with atypespecifier. That is all that it does. It does not encode logic to ensure that an import that has no value specifiers should use a top-level qualifier because as far as we knew this was a purely stylistic choice.From talking to the community it seems most people still believe it is (which is technically correct... In some cases).
There are other lint rules that can enforce whether you use top-level or inline specifiers, but none so far that enforce that allow inline specifiers, but enforce that a top-level qualifier be used if there are no value specifiers.
andrewbranch commentedon Jan 27, 2023
Would you be open to including such a rule in typescript-eslint?
bradzacher commentedon Jan 27, 2023
Discussing the best course of action here:
import-js/eslint-plugin-import#2676 (comment)
ljharb commentedon Jan 27, 2023
Side-effecting imports, by almost universal convention, do not export anything - as such, it's advisable to assume that there are only side effects if the file is imported without bindings, including types. An import statement that only brings in types should mean that the entire import statement is removed, so that the file isn't included at all.
jakebailey commentedon Jan 27, 2023
Just to look at this at another point of view; if we use the same sort of type-erasure scheme as the "types in JS" ECMAScript proposal uses, this code:
Would be read by the runtime as:
Because only the types get erased. If I were to instead write
import type { Foo } from "bar", the entire import is a type import, so fully gets erased.That means the question "should this module be loaded?" becomes "does the import declaration have a type keyword?". To me this is a simple rule, compared to "and also, if all specifiers have
type, then also do nothing", and less likely to break things (as mentioned in the original PR description).(But, this is of course my own mental model of things.)
ljharb commentedon Jan 27, 2023
Jake Bailey (@jakebailey) i agree the most conservative approach would do exactly this - but I don't think it's actually beneficial for the ecosystem to tacitly endorse modules with exports also having side effects, by being concerned with breaking those use cases.
robpalme commentedon Jan 27, 2023
Just exploring the auto-removal-if-no-value-bindings suggestion, what runtime behavior would we expect from these files? Should they match?
ljharb commentedon Jan 27, 2023
Those specific two, sure. But
import { type x } from 'path'is NOT simplyimport {} from 'path'with a type import added on, it's "i like that style better and i only added this import statement to get the type".andrewbranch commentedon Jan 27, 2023
For a bit of historical context, the pattern that motivated
importsNotUsedAsValueswas from an older version of Angular, where it was indeed very common to export things and have side effects in the way of DI registration. TypeScript has always preserved side-effect-only imports (ones with no import clause whatsoever); the complaint was that code like this was needed in order to ensureSomeServicegot registered in the right order or something:Completely disagree. Different syntax was added for different use cases and has different behavior.
Also, just to make sure everyone realizes, none of this is new with
verbatimModuleSyntax. Elision has always worked this way withimportsNotUsedAsValuesandpreserveValueImports.ljharb commentedon Jan 27, 2023
Andrew Branch (@andrewbranch) why the syntax was added is never guaranteed to be the same reason someone chooses to use it.
I would argue that the two-line approach is hugely preferable, and even better would be avoiding that hazardous design pattern in angular itself (which "older" implies, they did move to avoid?)
andrewbranch commentedon Jan 28, 2023
I used to work on a big web app with an absolute spaghetti of cyclic dependencies where everything constructed singleton classes at load time that all depend on each other. Having been scarred for life, I don’t need convincing that the pattern is bad 😄. But Angular version whatever-point-oh was pretty popular, and it was one of our most upvoted issues.
But I don’t think we have to justify past motivations to make sense of this.
verbatimModuleSyntaxis an opt-in flag that makes imports and exports emit as close to what you wrote as possible. If you want imports to be elided wherever possible, that’s literally the default. If you want that, but also want to be transpiler-friendly, that is and always has beenisolatedModules. Neither of these modes is going away. 99% of people who were usingimportsNotUsedAsValueswere using it for linty reasons. People who want that can use a linter.My top priority is to keep
verbatimModuleSyntaxsimple and explainable, which means not changing the emit back to do magic elision. What I’m potentially more willing to budge on is whether there should be a way of keeping the error thatimportsNotUsedAsValueshad that makes sure you writeimport type(fully elided import declarations) whenever possible. I thought that concern was kind of linty, and if we issued an error for it predicated onverbatimModuleSyntax, we would be un-fixing the old Angular-inspired issue we fixed. Those users would have to go back to using double imports, or// @ts-ignorethe error. We could error under a separate flag, but I really hoped to reduce the complexity matrix here. While I haven’t really heard any compelling reasons why this isn’t a lint issue, I’m open to feedback on that.zanminkian commentedon Jun 1, 2023
import=andexport=syntax are unusable for development experience reason.That means, developers cannot enableverbatimModuleSyntaxif they are incommonjs, because no one useimport=andexport=syntax even they compile the ts tocommonjs. Developers who are inesmshould enable this flag.This flag is really unfriendly to
commonjsproject.andrewbranch commentedon Jun 1, 2023
Yep that’s what the PR says. It’s also what the new in-progress module docs say.
icyJoseph commentedon Jun 2, 2023
Edit
I found it! #53683
Working as expected 😄 And the
moduleDetectionflag could be set to "force" to treat files as modules by default.OP
I am really not sure if this is the forum/thread for this, but it is what I was able to trace using
--isolatedModulesin the switch from 4.9.5 to 5.0.x.Given this file:
When using
4.9.5it causes this, expected, error:So far so good. Now with the same
tsconfig.json, upgrading to anything on the 5.x.x range, has stopped raising the error, and the code compiles normally.tsconfig
The
tsconfig.jsonfile used is thetsc --initdefault with this modification:+ "isolatedModules": true,Perhaps any of you can recognise this change in behavior? Perhaps it is expected to begin with? Thanks!