Search code examples
typescriptvisual-studio-codetsc

How to tell TSC that script files will not share scope and ignore redeclarations


Problem / Question

I'm using the TS-powered JS checking abilities of VSCode to type-check a bunch of JS files. These are files that are going to be imported via <script> tags in HTML, and contain no export/import statements, so they truly are script files, not module files.

Normally, with script files you cannot redeclare a block-scoped variable. E.g.:

  • file_A.js: let myStr = 'hello';
  • file_B.js: let myStr = 'hello'; // <-- Error, Cannot redeclare block-scoped variable 'myStr'

However, these files have natural separation, due to their file structure:

 - jsconfig.json (or tsconfig.json)
 - dir_A/
     - index.html (uses file_A.js via script tag)
     - file_A.js
 - dir_B/
     - index.html (uses file_B.js via script tag)
     - file_B.js

Is there an easy way to avoid this TS error...

Cannot redeclare block-scoped variable 'myStr'.ts(2451)

... by telling VSCode / TSC that although file_A and file_B both declare the same variable (let myStr = 'hello'), it is not a re-declaration, because these files are never executed / imported within the same scope (there is no HTML file or script that executes both file_A.js and file_B.js)?

Solutions that work, but are a pain are:

  • Put a config file in each sub-directory, remove the root config
  • Encapsulate the code in each JS file with an IIFE
  • Change variable declarations to use var
  • Add empty export (export {}) to each .js file to force it to be a module, then have build step that removes that line as it copies files into /dist

I'm hoping there is something to do with either ambient declaration files or the config to tell TS to essentially treat these as modules, despite them having no import / export declarations.

I also realize that I can add an empty export declaration (export {}) to each file to convert it into an actual module, but then I have to use <script type="module"> in the consuming HTML, which is not ideal for my given scenario (I lose legacy browser support, as well as the ability to use automatic top-level scoped variables).

TLDR;

The main crux of my question is: "is there any easy way to tell TSC to treat script files as modules (since they will be consumed that way) despite them having no import / export / module syntax?"


Full Reproducible Example

Files:

.
 |-dir_A
 | |-file_A.js
 |-dir_B
 | |-File_B.js
 |-tsconfig.json

I left the index.html files outside of the above tree, because they don't affect TSC, and the error occurs with or without them.

Both file_A.js and file_B.js contain identical code:

let myStr = 'hello';

I've tried multiple variations of my config, but the minimal config is:

{
    "compilerOptions": {
        "checkJs": true,
        "allowJs": true,
        "noEmit": true,
    }
}

I've also tried this with "isolatedModules": true, but that doesn't work.


EDIT: Closest answer so far

After some additional searching, I think I found what is closest to an "official" answer, although it is still unsatisfactory in that there is no good solution at the moment.

This thread, issue #18232 is basically the main discussion thread, as it discusses the issue of file scoping with scripts vs modules, and how TS can't know exactly how a file is going to be consumed. The issue is still open, and led me to discover some other very relevant links:

  • This issue / feature request (#27535) is pretty much my exact desired solution; a TSC flag / option that would let me tell it to treat script files as modules. It was closed, because it overlaps with the thread I linked to above.
  • This StackOverflow question, which has one of my "unsatisfactory" solutions; just use modules instead of scripts (this is not addressing the issue)
  • TC39 Proposal - "Modules Pragma"
    • This is probably the optimal solution; pragmas can be understood by both TSC and the browser, and they don't break older interpreters / engines (unlike the empty export {} hack, which requires module support)

Sadly, the thread above has not seen traction in over a year.


Solution

  • There is now an official solution for this problem, which does not require workarounds (like empty exports)! TypeScript has added a compiler option (in v4.7), moduleDetection, which changes how files are treated as modules (or not) by default.

    To fix the issue outlined in the original question and have each .js file treated as a module without requiring explicit imports or exports be added, make sure the tsconfig.json file has "moduleDetection": "force", like so:

    {
        "compilerOptions": {
            "checkJs": true,
            "allowJs": true,
            "noEmit": true,
            "moduleDetection": "force"
        }
    }
    

    For more details on this feature, see: