microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.28k stars 12.39k forks source link

TypeScript won't import JSDoc types from .js file in node_modules #33136

Closed rbiggs closed 4 years ago

rbiggs commented 5 years ago

TypeScript Version: 3.5.2

I use JSDoc type definitions in my JavaScript and use checkjs in VSCode to have TypeScript type check my JavaScript live. This generally works very well. However I have noticed one failure. TypeScript fails to properly understand JSDoc types when they're imported from another file. Let me explain.

I have a node module which uses JSDoc to define types. Some of the types are defined in one file and imported into other files using the format:

// vnode.js
/**
 * @typedef {Object.<string, any> | {}} Props
 * @property {Children} Props.children
 */
/**
 * @typedef {VNode[]} Children
 */
/**
 * @typedef {string | number | Function} Type
 * @typedef {number | string | null} Key
 * @typedef {Object.<string, any>} VNode
 * @property {Type} VNode.type
 * @property {Props} VNode.props
 * @property {Children} VNode.children
 * @property {Element} VNode.element
 * @property {Key} [VNode.key]
 * @property {number} VNode.flag
 */

This type VNode gets imported in another file for use as a parameter of a function:

// render.js
/**
 * Render a functional component. The first argument is the component to render. This can be either a JSX tag or an `h` function. The second argument is the container to render in. An optional third argument is an element in the document loaded from the server. This will be hydrated with the component provided to render. This third argument can be a DOM referece for that element, or a valid string selector for it.
 * @typedef {import('./vnode').VNode} VNode
 * @param {VNode} VNode
 * @param {Element | string} container
 * @param {Element | string} [hydrateThis]
 * @return {void} undefined
 */

In the module itself this works as expected. The type VNode is understood everywhere it is used.

The problem is when I create a project the uses this module. For some reason TypeScript treats the imported JSDoc type as any. I'm currently using VSCode Version: 1.37.1. When i was using 1.36.x I was getting the following warning for the import JSDoc type (sic):

TypeScript cannot find a `d.ts` file for `./vnode`. As such its types will be treated as `any`.

What I don't understand is why would TypeScript be looking for a d.ts file for a JavaScript file using JSDoc comments for types? Does it know that an import in a JSDoc comment should point to a JSDoc type definition? It seams like when TypeScript sees the @typedef import statement, it assumes its a normal JavaScript import and starts looking for a d.ts file. Which leads to it never importing the JSDoc type.

Of course I could copy and paste the type definition everywhere I need it. However that leads to maintenance problems when I need to update the type definition.

Since this works inside the module importing JSDoc types from other files, why can't it work when the module is imported into another project?

To show the problem here are some images illustrating a module with a render function. In the first image you can see that the imported VNode type is being imported and interpreted correctly:

1-render-source

In the next image you can see that this imported type is being interpreted correctly as a parameter of the render function when this is defined:

2-render-source

But here, when the render function is imported into a project, the imported type is treated by TypeScript as any:

3-render-imported

To reproduce this problem do the following.

npx @composi/create-composi-app -n Type-Test

The above will creaate a new project on your desktop named Type-Test. cd to the new project and run:

npm install

After the dependencies are installed, open the project in VSCode and go to the src/js/app.js file. Hover over the imported render function at the top of the page. You'll see Intellisense pop up, but the first argument for VNode will be of type any. This is wrong.

Now open the project's node_modules folder and go to node_modules/@composi/core/src/render.js Scroll down to the defintion of the render funcion. Hover over it or its first parameter. You'll see that here TypeScript is able to correctly understand the imported type as VNode, not as any.

It seams TypeScript is treated JSDoc import statements as if they were JavaScript imports. It then looks for a .d.ts, which doesn't exist because this is JavaScript with JSDoc comments. So it defaults to any.

Interestingly, in earlier versions of VSCode, if I opened the module source code first and hovered over the imported type, this would force VSCode and TypeScript to recognize the type, even in the project where the modules was imported. Currently this no longer works, instead treating such imported JSDoc types as any no matter what you do.

rbiggs commented 5 years ago

Update

Recently I noticed a situation where TypeScript was able to properly import JSDoc types from an imported module. In the case of my NPM module @composi/core, it has the following file structure:

+ core
  + src
    --constants.js
    --effects.js
    --fragment.js
    --h.js
    --index.js
    --render.js
    --runtime.js
    --union.js
    --vdom.js
    --vnode.js

Of the above, index.js exports functions defined in the other files like so:

export { h } from './h'
export { render } from './render'
export { run } from './runtime'
export { union } from './union'
export { batchEffects } from './effects'
export { Fragment } from './fragment'

When using @composi/core, a project usually imports functions like this:

import { h, render, run, union, batchEffects } from '@composi/core'

@composi/core's package.json has the index.js as the main point of entry:

"main": "src/index.js"

However, for better tree shaking you could just import the functions you want directly from the other files in the source directory, bypassing the index.js file. You would do that like this:

import {h} from '@composi/core/src/h'
import {render} from '@composi/core/src/render'

When one does this, all of a sudden TypeScript imports the types correctly for h and render without casting them to any.

I'm suspecting that the indirection of the index.js file is what is causing the problem. TypeScript is probably going to the index.js file. There's no JSDoc comments there because its just imports and exports. There is also no d.ts associated with the index.js file. TypeScript follows the path to the individual files. It sees no d.ts files for them. It sees the import of JSDoc types. Then it looks for a d.ts for them and doesn't find one. So it treats the types as any. Of course this is my hunch. I don't know the details of how TypeScript actually handles this whole process of understanding JavaScript type detection between ordinary untyped JavaScript and JavaScript with JSDoc comments. But I'm suspecting there is something amiss in what decisions it makes as it follows the path of an JavaScript module import, especially concerning modules with d.ts files and without.

phaux commented 5 years ago

There's an option in TS compilerOptions called maxNodeModuleJsDepth and it's set to some low value by default. Try setting that to a higher value in the client program's tsconfig.

rbiggs commented 5 years ago

maxNodeModuleJsDepth is for TypeScript. I'm talking about JavaScript files with JSDoc comments. I'm not compiling them since they're already JavaScript. I'm importing them into a JavaScript project that uses checkjs for live type linting.

Now, projects do have a jsconfig.json file with compiler options, but setting that with , "maxNodeModuleJsDepth": 5 or higher doesn't seem to make a difference. Here's my jsconfig.json:

{
  "compilerOptions": {
    "target": "es6",
    "jsx": "react",
    "maxNodeModuleJsDepth": 5
  },
  "include": [
    "node_modules/@composi/**/src/*",
  ]
}
rbiggs commented 5 years ago

Update

I found a hack for countering TypeScripts inability to properly process imported JSDoc types. I imported the file with the types that other files were using into the module's index.js file. By default I wouldn't do this because all the functions defined in vnode.js are only used internally and should not be exposed to the end user. However, by importing it into index.js, its types get exposed to TypeScript so that when it sees them being imported in other files, it parses them correctly.

Of course this is a hack that illustrates the problem. A module with an index.js file that assembles functions from disparate files for export causes TypeScript to fail to follow their import of JSDoc types.

My old index.js file, as mentioned above was like this:

export { h } from './h'
export { render } from './render'
export { run } from './runtime'
export { union } from './union'
export { batchEffects } from './effects'
export { Fragment } from './fragment'

My new version that forces TypeScript to recognize imported JSDoc types is like this:

export { h } from './h'
export { render } from './render'
export { run } from './runtime'
export { union } from './union'
export { batchEffects } from './effects'
export { Fragment } from './fragment'

/**
 * The following import is a hack to force TypeScript to properly
 * understand JSDoc types defined in the vnode.js file that are used by
 * fragment.js, h.js, render.js and vdom.js.
 * When TypeScript gets updated to handle this import properly, this will be removed.
 */
import { createVNode } from './vnode' // eslint-disable-line no-unused-vars

Note that the createVNode function is imported but not exported. It shouldn't be exposed to the user. It's merely being imported to expose the JSDoc types defined in that file so that TypeScript can understand them when it encounters them in the other functions that are getting exported.

rbiggs commented 5 years ago

I'm currently using TypeScript 3.6.3. I'm noticing ellipsis before the imported module name for @composi/core. When I hover over the ellipsis, I get the following notice about not finding a d.ts file for @composi/core. Please note the @composi/core is fully typed using JSDoc comments. In fact hover over the individual imports from the module shows the correct types for them.

Screen Shot 2019-09-18 at 6 04 28 PM

Why should I need a d.ts file when the types are already fully defined in JSDoc comments?

engineforce commented 4 years ago

I have found a solution that seem to work well: move index.js to src/ folder, and create index.d.ts at root that point to it:

// src/index.js
export function delay() {}
// index.d.ts
import { delay } from './src'
export { delay }
rbiggs commented 4 years ago

I also found another hack to get TypeScript to import JSDoc types that weren't getting imported. Just make so @typedef imports on the index.js file for the module. Then TypeScript has to parse them at that level and they become available when you import the module in a project.

But I'm curious what you're doing with the d.ts file. Al it has is the import of the JavaScript functions with the JSDoc types you want to expose? Hmmm.... I'll try that, but I really don't want to have to create a d.ts file. I'm of the opinion that TypeScript should be able to understand the types of a JavaScript imported module based on its JSDoc type definitions.

rbiggs commented 4 years ago

I tried using a d.ts like you suggested to point TypeScript toward the type definitions, but that did not work. Instead, this is what works for me. I have the following expected exports in the module's index.js file, followed by @typedef pointing to the type definitions in JSDoc that I want TypeScript to be aware of globally:

export { h } from './h'
export { render } from './render'
export { run } from './runtime'
export { union } from './union'
export { batchEffects } from './effects'
export { Fragment } from './fragment'

/**
 * Make types available to programs that use them.
 */
/**
 * Type of virutal node used to define elements to create.
 * @typedef { import('./vnode').VNode } VNode
 */
/**
 * A program which is executed by the `run` function.
 * @typedef { import('./runtime').Program } Program
 */
/**
 * Message dispatched by the `send` function to the program's `update` method.
 * @typedef { import('./runtime').Message } Message
 */
/**
 * Type of state, which can be of any type.
 * @typedef { import('./runtime').State } State
 */
/**
 * Function for sending messages to the program's `update` method.
 * @typedef { import('./runtime').Send } Send
 */

This allows the user to import h, render, etc. and get the correct types in Visual Studio Code.

lukehesluke commented 4 years ago

For anyone else who has found this issue after it closed, hopefully this helps:

Same Problem

I have the same problem as @rbiggs. I've created a module called @imin/shared-data-types. This module uses almost entirely JSDoc to define its TypeScript types. Within the module, all the files are able to use the types correctly. VSCode recognises if there's a type error and so does tsc. That's great :heavy_check_mark:

However, as soon as I'm importing this code to another project, the types disappear. VSCode and tsc both recognise the types as any :x:

Hope?

@phaux 's solution (https://github.com/microsoft/TypeScript/issues/33136#issuecomment-528657072) was very helpful to me. I set my tsconfig.json to:

{
  "compilerOptions": {
    "noEmit": true,
    "allowJs": true,
    "checkJs": true,
    "downlevelIteration": true,
    "maxNodeModuleJsDepth": 1
  },
  "include": [...]
}

And VSCode and tsc were finally able to recognise the types!

Before:

Screenshot from 2020-01-27 10-56-17

After:

Screenshot from 2020-01-27 10-58-01

Wonderful :heavy_check_mark:

The only problem is that when I ran tsc I was shown a LOT of new errors.

Screenshot from 2020-01-27 11-01-45

This is because now every single import is being type checked. Many JavaScript libraries come a-cropper when examined under TypeScript's inscrutable gaze. Fair enough! But I don't have time to fix all these issues with external libraries. :x:

What I want is for TypeScript to just check the modules that I made that I know have been typed properly using JSDoc. How can I do that?

The solution

Just add the modules to tsconfig.json's include list. e.g.:

{
  "compilerOptions": {
    "noEmit": true,
    "allowJs": true,
    "checkJs": true,
    "downlevelIteration": true
  },
  "include": [
    "src/**/*.js",
    "node_modules/@imin/shared-data-types/**/*.js",
  ]
}

This works! :heavy_check_mark: :heavy_check_mark: :heavy_check_mark:


Typescript version: 3.6.2

rbiggs commented 4 years ago

Actually, @lukehesluke, that's what I wound up doing as well, using include to tell TypeScript to include my JavaScript modules with JSDoc comments. One thing I also had to do what make sure the main index file imported any types being used in sub-files so that when TypeScript hit the index.js file for the module, it also found the path to the types.

However, now I no longer bother with any of that because TypeScript now supports creating d.ts files from JavaScript files with JSDoc comments. So when I run tsc, it automatically generates .d.ts files for everything. This solves the problems that TypeScript still has with following the paths of types defined with JSDoc in complex JavaScript modules. Well, it doesn't fix the issue. It gets around the problem because by default TypeScript always looks for d.ts files. Since my modules are written in JavaScript with JSDoc comments, there weren't any. Now I can let TypeScript create them for me while its also type checking my JavaScript.

You just need to update your projects tsconfig.json file for this to work. Here's what I'm currently using for compiler options:

{
  "compilerOptions": {
    "target": "es6",
    "allowJs": true,
    "checkJs": true,
    "moduleResolution": "node",
    "alwaysStrict": true,
    "strictNullChecks": false,
    "emitDeclarationOnly": true,
    "declaration": true,
    "outDir": "types",
    "removeComments": false
  }
}

Note that I tell TypeScript to output the d.ts files into a folder called types. Then in my package.json file I declare that as the location for the project's types:

"typings": "types"

With these updates to my JavaScript projects, TypeScript will find the d.ts files for my JavaScript projects. And these are created by TypeScript based on my JSDoc comments. Win win.

I guess I should mention how I check my types and produce the d.ts files. Because I have all the instructions for TypeScript in the project's tsconfig.json file, all I have to do is run a simple npm script which I define in my package.json:

"scripts": {
  "checkjs": "tsc"
}

Then in the terminal I just run npm run tsc. This runs a type check on the JavaScript using my JSDoc comments and then produces d.ts files as well.

lukehesluke commented 4 years ago

@rbiggs you genius! Thank you, that looks like the ideal solution to this. Am I right in saying that this is primarily these compiler options:

  "emitDeclarationOnly": true,
  "declaration": true,
rbiggs commented 4 years ago

Yup. You do need the latest TypeScript installed somewhere, either locally or globally. Latest version is 3.7.5. Set up your tsconfig.json file. Then setup an NPM script to run TypeScript to check you files. With this above options in the tsconfig file it will also create the d.ts files for your JavaScript. That means you don't have to worry about TypeScript importing you JSDoc types properly. It will always find and parse d.ts files. Here's a link to a tsconfig file for one of my Gitub repos: https://github.com/composi/core/blob/master/tsconfig.json

samuelstroschein commented 11 months ago

This issue should be re-opened given that TypeScript does not resolve JS files with JSDoc from node_modules.

All proposed solutions in this issue are workarounds. Using the TypeScript compiler to emit declaration files is redundant, given that the JS files contain the declaration already. Moreover, I experienced:

All of those issues would exist if declaration files wouldn't be required for JSDoc JS files.

phaux commented 11 months ago

I think TypeScript should simply support main and types fields in package.json being set to the same file if the file is JS with JSDoc:

{
  "main": "./main.js",
  "types": "./main.js"
}

Last time I checked it didn't work. Project which imports such module doesn't get the types.

kungfooman commented 8 months ago

Last time I checked it didn't work. Project which imports such module doesn't get the types.

Fast forward to 2024 and it still doesn't work and issue is still closed... nothing is fixed here, just hacky and annoying workarounds in sight.

ilyaigpetrov commented 8 months ago

Please, reopen the issue. Thumbs up this message if you want it to be reopened. Thanks.

hyf0 commented 4 months ago

I think TypeScript should simply support main and types fields in package.json being set to the same file if the file is JS with JSDoc:

{
  "main": "./main.js",
  "types": "./main.js"
}

Last time I checked it didn't work. Project which imports such module doesn't get the types.

Happy to shared that I have found a way to do similar things and its more friendly due to not need to config include or maxNodeModuleJsDepth.


In short, you just need to create a facade .d.ts file for each entry that you want to export and export * from these entries in .d.ts files. Then, add .d.ts to package.json#exports field. It will enforce typescript to read types on js + jsdoc files.

https://github.com/hyf0/starter-libesm is an example.


Too bad you still need to enable package.json#checkJs in the project to enable type checking on .js files in node_modules.