Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSDoc types.js cannot be used for package-level and export-level types #50436

Closed
coolaj86 opened this issue Aug 24, 2022 · 8 comments
Closed
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@coolaj86
Copy link

coolaj86 commented Aug 24, 2022

Bug Report

🔎 Search Terms

JSDoc exports package scope

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about JSDoc, exports, "cannot find"

⏯ Playground Link

N/A This occurs between multiple files, not in single file.

💻 Code

Problem

Catch 22:

If I don't include export default {} (or module.exports = {}) then I can't import the types from another module.

If I do include the export, then files within the packages no longer have package-scope access to the types.

I've also opened a question looking for a workaround on StackOverflow:
https://stackoverflow.com/questions/73480632/how-can-i-export-package-scope-jsdoc-types

Example A: Can't import 'Foo' types from package 'Bar'

Everything within the package itself can access its own types just fine - but they can't be exported.

node_modules/foo/index.js:

/**
 * @param {Foo} foo
 */

node_modules/foo/types.js (in jsconfig.json.includes):

/**
 * @typedef {Object} Foo
 * @prop {String} name
 */

bar.js:

/** @type {import('foo/types.js').Foo}  */
let foo;
❌ [tsserver] Cannot find name 'Foo'.

Example B: Can't access 'Foo' types from package 'Foo'

Now external packages have access to Foo, but the Foo package itself can't access it.

node_modules/foo/index.js:

/**
 * @param {Foo} foo
 */
❌ [tsserver] Cannot find name 'Foo'.

node_modules/foo/types.js (in jsconfig.json.includes):

// workaround: forces tsc's export machinery to kick in
module.exports = {};

/**
 * @typedef {Object} Foo
 * @prop {String} name
 */

bar.js:

/** @type {import('foo/types.js').Foo}  */
let foo;

Now bar.js can find the type Foo without issue.

🙁 Actual behavior

[tsserver] Cannot find name 'Foo'.

🙂 Expected behavior

  • the package itself should have access to Foo
  • 3rd packages should be able to import the type Foo

Having one shouldn't break the other.

@coolaj86
Copy link
Author

coolaj86 commented Aug 25, 2022

Complete Reduced Test Case

Here's a reduced test case with all the tsconfig.json, package.json, etc:

https://github.com/coolaj86/test-case-tsc-exports

.
├── README.md
├── main.js
├── tsconfig.json
└── node_modules
    ├── bar
    │   ├── bar.js
    │   ├── package.json
    │   ├── tsconfig.json
    │   └── types.js
    └── foo
        ├── foo.js
        ├── package.json
        ├── tsconfig.json
        └── types.js

@andrewbranch andrewbranch added the Working as Intended The behavior described is the intended behavior; this is not a bug label Sep 1, 2022
@andrewbranch
Copy link
Member

andrewbranch commented Sep 1, 2022

There’s no such thing as “package scope,” perhaps unfortunately. There are only two ways to access things cross-file: (1) accessing a global, or (2) importing an export. In any TypeScript or JavaScript file under default compiler options, files that don’t contain any imports or exports (or require in JS) are assumed to be global scripts, which means all top-level declarations in them are global.

Everything within the package itself can access its own types just fine - but they can't be exported.

So in fact, everything everywhere in the program, not just in the package, can access the type declared in node_modules/foo/types.js. The foo package, your own source code, and every other package in your compilation too.

In your next permutation:

Now external packages have access to Foo, but the Foo package itself can't access it.

So again, packages have nothing to do with it. When you add export syntax to types.js, it becomes a module, and JSDoc declarations are implicitly exported (since there exists no syntax to export them explicitly). That means every other file, whether in the foo package or outside of it, can only access types.js through an import.

While your expected behavior would definitely be convenient, there’s just no concept of scoping like that for JavaScript values (e.g. I can’t declare a variable that is automatically accessible in every file in package foo without import and yet not accessible from files outside the package—everyone shares the same global scope), and the scoping for types simply obeys the same rules as values. So the behavior you’re seeing isn’t just about JSDoc or even just about types—you’re just observing the difference between globals and module exports in general.

@coolaj86
Copy link
Author

coolaj86 commented Sep 2, 2022

there’s just no concept of scoping like that for JavaScript values
... the scoping for types simply obeys the same rules as values
... you’re just observing the difference between globals and module exports in general.

Well, node's been around a lot longer that TypeScript, and TypeScript is built for node, so one would reasonably expect that TypeScript as a super-duper JavaScript linter would know to keep the boundaries around package.json

Nevertheless, thanks for the detailed answer. 🙂

Hmmm... dangit. JSDoc + TypeScript has so many edge cases and bugs / working-as-intendeds-but-still-actually-bugs...

We need a super-duper-linter + type-checker for JavaScript that really, really gets JavaScript. 🤷‍♂️

I'll just go fix it the easy way... 😕🔫
(but not really)

@coolaj86 coolaj86 closed this as completed Sep 2, 2022
@coolaj86
Copy link
Author

coolaj86 commented Sep 2, 2022

Could I perhaps convert this to a feature request?

@andrewbranch
Copy link
Member

I hoped that my answer would imply that such a feature would be an architecturally heavy lift, and likewise not actually desirable in terms of maintaining an explainable mental model of scoping. I said that your expected behavior would be convenient, because at first glance it would certainly save you some keystrokes. But we really like that scoping and module rules are equivalent for types and values, because you only have to learn one set of rules, and those rules aren’t even specific to TypeScript. Globals are globals, exports are exports. The confusing part, in my opinion, is that JSDoc declarations automatically become exports as soon as the file becomes a module. Also, from experience, I have found that most people don’t assume that their files are global scripts just because they don’t currently have any imports or exports. For that, we have the --moduleDetection force flag, and unless you’re actually writing scripts to go in <script> tags, it’s a pretty good thing to turn on. That will disable your declarations from being global.

That said, global access to your types in JS is pretty nice, since the syntax to import them is cumbersome. My advice for JS projects that aren’t libraries shipping to npm: define your types as globals in a .d.ts file. The syntax is much easier than @typedef. And then if you need to make those types not conflict with other globals, you can wrap them in a namespace:

// types.d.ts
namespace mypackage {
  export interface Foo {
    x: string;
  }
}

// index.js
/** @param {mypackage.Foo} foo */
function f(foo) {}

@coolaj86
Copy link
Author

coolaj86 commented Sep 2, 2022

I hoped that my answer would imply

I don't do implied. Not one of my brain functions.

The confusing part, in my opinion, is that JSDoc declarations automatically become exports as soon as the file becomes a module.

Yes, very confusing. To a lot of people (everyone I've been asking about this on Twitter, Slack, StackOverflow, in the office, at least).

you can wrap them in a namespace

Does this mean that when you import them from another JavaScript project that it will also have to use the namespace?

/** @typedef {import('mypackage').mypackage.Foo} Foo */

Or are the namespaces global as well?

@coolaj86
Copy link
Author

coolaj86 commented Sep 2, 2022

--moduleDetection force

I don't see this in the comments for a default tsconfig.json from tsc --init. Is it only a command line flag?

@andrewbranch
Copy link
Member

Does this mean that when you import them from another JavaScript project that it will also have to use the namespace?

The namespace is global if the stuff in the file is global, which is dependent upon moduleDetection and whether the file has top-level imports or exports, same as I described earlier. I only suggested wrapping in the namespace if it’s going to be global—it means you have one global name declared instead of one for every type you need. If you’re happy exporting all your types from modules, no need to add the extra namespace wrapper.

Is it only a command line flag?

No, not every tsconfig option shows up in the --init template.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

2 participants