3

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.

Joshua T
  • 2,439
  • 1
  • 10
  • 42
  • *Encapsulate the code in each JS file with an IIFE* This is the approach I'd prefer - ideally IIFEs would be used everywhere anyway. You could also probably use Node to iterate through the files and call `tsc` on each individually, while pointing to the right config file. – CertainPerformance Nov 14 '20 at 15:58
  • @CertainPerformance Yeah, IIFE is definitely what I'm going to be using if I can't find an alternative. The downside is that I then have to explicitly write things like `globalThis.myStr` or `window.myStr` if I actually wanted those variables to be global, as well as deal with the type annotation issues that come with that. I should also clarify that I'm not actually running these files against TSC at this time; this is just for the VSCode intellisense / problem reporting, which uses the TSC system. – Joshua T Nov 14 '20 at 19:10
  • Can you post your tsconfig? Local variables should be isolated, if the files are treated as modules. So it's very puzzling that typescript says "cannot redeclare". – Matthias Nov 16 '20 at 19:36
  • Can you post some more contents of `file_B` where `myStr` is mentioned? – Matthias Nov 16 '20 at 19:39
  • @Matthias That is precisely the issue; the files are *not* treated as modules, since they contain no module-specific syntax. TS can't / doesn't scan my HTML files to know these are never imported together. I've updated my answer with my full config and file contents. I also believe I found what is probably the only answer at this point (which is really a non-answer / "blocked" issue). – Joshua T Nov 17 '20 at 00:22
  • Have you tried creating a separate tsconfig.json file in each sub-directory? You can put the common configuration in a separate file, e.g. "shared.tsconfig.json", and add "extends" : "../shared.tsconfig.json" to each tsconfig file. – lazytype Nov 20 '20 at 09:57
  • @lazytype Yep. See "Solutions that work, but are a pain are" section of my question. I don't really consider a workable solution; think of a TS project that has a `demo` folder, with 100 different static HTML / JS subfolders - you would have 100 x tsconfigs. Not to mention that makes sharing *true* globals messier. – Joshua T Nov 22 '20 at 06:47
  • Having a tiny npm script to generate these files could lessen that pain. Since this is merely to satisfy VSCode you could also add these files to your .gitignore and try to forget about them. – lazytype Nov 22 '20 at 08:08

1 Answers1

3

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:

Joshua T
  • 2,439
  • 1
  • 10
  • 42