Open basickarl opened 3 years ago
I also really need '@interface' support, I expect to use type federation in JS to support multiple type merges (based on JSDoc)
Interface
s in JSDocsPinging @sandersn as requested in #42877.
Currently, I'm working on a JS library that will work in all JS applications. Let's call it, @my-scope/core
. For the sake of developer experience, I'm also releasing framework-specific integrations of this package. These packages make adoption of the core
library easier by making the tool more ergonomic in the framework where it's used. This means I have packages like @my-scope/svelte
, @my-scope/react
, etc.
All of these packages are intended to use regular JS Files. (There are no TypeScript files, except when .d.ts
is necessary to get the features that are lacking in JSDocs.) All of the framework-integration packages build on top of the core
. In part, this means that some of the types in the core
get reused in a framework integration. Such reuse may look something like the following (if I was using TypeScript):
// Somewhere `@my-scope/core`
interface CoreInterface {
common-prop: string;
overridable-prop: HTMLElement;
}
// Somewhere in `@my-scope/react`
interface ReactInterface extends Pick<CoreInterface, "common-prop"> {
overridable-prop: React.ReactElement;
}
The biggest benefit of interface
+ extends
here is reuse. Currently, I have a core
and 4 supported JS Frameworks. It would be difficult, tedious, and (worst of all) error prone to try to repeat + synchronize the common prop names, prop types, and especially the prop documentations across 5 different libraries. If @interface
existed with support for @extends
, I could reuse my types and documentation as desired.
Currently, my core
package defines @typedef
s, not interface
s. But since there's no way to extend these types with JSDocs, I have to create .d.ts
files in the framework packages. This means I end up creating a separate file for an augmented type that will only be used in a single JS file in a given framework package. This is undesirable because the maintainer has to leave their context (the .js
file or the .d.ts
file) to get the "full story" for a certain piece of functionality. Users of .ts
files do not have this problem.
Currently, it's possible to document all of the details of a method defined on an interface. This includes overloads.
interface MyInterface {
/**
* Does something really cool
* @param a Some string that does some stuff
* @param b Some number that isn't interesting
* @returns `true` or `false`
*/
method(a: string, b: number): boolean;
/**
* Does something that isn't as interesting as the first overload
* @param a You can choose `true` or `false`
* @param b Your coolest object
* @returns Our opinion about your parameters
*/
method(a: boolean, b: object): string;
}
Unfortunately, none of this is possible with @typedef
(to my knowledge). You can certainly document a property that behaves like a function:
/**
* @typedef {Object} MyInterface
* @property {((a: string, b: number) => boolean) | ((a: boolean, b: object) => string)} Uh...
* This does something depending on how you use it. Take your best guess?
*/
But you can't provide proper documentation for parameters, return values, overloads, and the like. You could try to write the documentation for everything on a single property. But it's obvious how that could get verbose, overwhelming, and unhelpful very quickly.
As you could guess, I also need this for the package that I mentioned earlier. Without it, I have to defer to .d.ts
files again. But I don't want to reach for .d.ts
files as a maintainer.
.d.ts
Files?Nothing! I like them! But for projects where JSDocs are being used, I'd like to stay in JSDocs land as much as possible. I think that what's coming out in issues like these is that developers would like a "Consistent TypeScript Experience" whether they're using JSDocs or TypeScript files. Part of that consistent experience is being able to collocate types with logic. (If that isn't interesting to the team, that might be worth documenting somewhere for clarity -- though it would be unfortunate. :sob:) In TypeScript, I could write
export function myFunction(a: string, b: number): MyInterface { /* ... */ }
export interface MyInterface { /* ... */ }
This approach has two benefits. First, users can import my tools with their related types from the same place. This makes it clearer where everything is located.
import { myFunction } from "my-library";
import type { MyInterface } from "my-library";
Second, developers/maintainers never have to change context to understand what's going on. There's no jumping to another file to understand a type that only relates to this single file. In other words, there's no need for something like this
import type { MyInterfaceOnlyRelatedToMyFunction } from "./filename-types.d.ts";
export function myFunction(a: string, b: number): MyInterfaceOnlyRelatedToMyFunction { /* ... */ }
Instead, everything is defined in the same place. So it's easier for the developer to know what's going on. This also saves users from having to search around to know where a type related to a specific file is located. The following is undesirable:
import { myFunction } from "my-library";
import type { MyInterfaceOnlyRelatedToMyFunction } from "my-library/where-do-i-go-for-this-type";
As a library maintainer using JSDocs, I would like for my users to have a consistent experience. That is, I want related function/class definitions and type definitions to be importable from the same location. I think that's natural, and React Components do this all the time; it would be weird if you had to import MyComponent
from one place and MyComponentProps
from another place. (If there are general types that are used across the project, I think .d.ts
files make more sense than .js
files. This creates a central place where common, reusable types exist, and it prevents library bundles from getting littered with useless export {}
JS files. However, I'm not talking about general/common types right now.)
But here's the problem I run into... Let's saying I'm creating a JS file with types created via JSDocs:
// my-file.js
/**
* @typedef {Object} MyType
* @property {string} first-prop
*/
/**
* Does something
* @param {MyType} first A parameter that does stuff
* @returns {void}
*/
function someFunction(first) { /* ... */ }
Then I realize some time later that my file needs an interface that adds good documentation for methods. Well, I can't add add that to my JS file. So I have to define it in a separate .d.ts
file. Where do I place it? It's not a general type belonging to the entire project. It's a type that only belongs to the JS file, and a type that would only be exported from that file if I was using regular TS. So the type doesn't belong in a "General Library Types" .d.ts
file. I can't place the type in my-file.d.ts
because the compiler would get confused. And it's too much effort to try to join the types together manually post-build. So I create my-file-types.d.ts
instead. But there are problems that come with this:
Now, consumers of the project start seeing inconsistencies throughout it. There are some files that export all of their types and all of their logic from one place because the types are simple enough to be supported by JSDocs alone. Then, because JSDoc @typedef
s will always be exported, there are other parts of the library where the JS file only exports a subset of the available types, and the remainder of the types must be found in a separate file. Will this file be easy for consumers to find?
For every JS file that can't be typed with JSDocs alone, a new file has to get added to the project. Extendable interface
s are a basic TypeScript feature. So if a project is sufficiently sized, it won't take too long for the file structure of the project to become more and more bloated. Naming conventions could be created in the project to prevent this from causing chaos, but to me this shouldn't be necessary.
package.json
Bloat for MaintainersThe project that I'm working on is mainly intended for the frontend. But the JS Framework integration packages require some server side rendering logic. Some SSR frameworks are used with CJS, so I need to support both ESM and CJS. Unfortunately, this means that for each JS file that requires an extra .d.ts
file to compensate for JSDocs, the package.json
file increases in bulk. What would ideally be the following
{
"type": "module",
"exports": {
"./filename": {
"require": {
"types": "./filename.d.cts",
"default": "./filename.cjs"
},
"types": "./filename.d.ts"
"default": "./filename.js"
}
}
is unpleasantly expanded to
{
"type": "module",
"exports": {
"./filename": {
"require": {
"types": "./filename.d.cts",
"default": "./filename.cjs"
},
"types": "./filename.d.ts"
"default": "./filename.js"
},
"./filename-types": {
"require": {
"types": "./filename-types.d.cts"
},
"types": "./filename-types.d.ts"
}
}
which is inconvenient for obvious reasons. Again, this happens for every JS file that can't be typed with JSDocs alone.
In the issue that I referenced, I referred to struggles with the build step for maintainers. I don't know if that was the correct terminology, per se. The lack of interface
support in JSDocs does cause difficulties for library authors, as described above. But there are no hiccups in the TypeScript compilation step.
Let me know if greater clarity is needed for what's been said or coded.
Some initial comments and questions.
@interface
that allows detailed @param
+@returns
? Specifically, I don't like the OP syntax with separated @interface
and @function
tags because (1) they're hard to bind (2) Typescript-equivalent tags should have exactly the semantics of their Typescript constructs, and preferably similar syntax.@export
tag for exporting types, right? At least it seems like it would make life a lot easier. Proposed in #48104 and #46011tsc
to emit .d.ts
from .js
files? Do you want it to?It depends on TypeScript's goal. I think the syntax suggested by the OP is based on what's currently acknowledged by JSDocs.
If TypeScript is willing to deviate from this, then I would agree that this syntax is not particularly pleasing. Perhaps a better syntax would be something similar to what @typedef
does. From what I can tell, @typedef
follow these rules: The description of a @typedef
comes from the text before the Name or the text between the Name and the first "important tag". (If information is provided after the type Name, then the text before the Name seems to do nothing -- which is fine.) Spacing is irrelevant; only the location of tags matters. Once an "important tag" is introduced, all of the succeeding text belongs to that tag. And this pattern continues for the rest of the type definition.
/**
* I describe `MyType` (if I'm the only description).
* @typedef {Object} MyType I also describe `MyType`.
* Yep. I describe `MyType` as well.
*
*
* Hey! I describe `MyType` too! And because there's at least 1 line of space between me and the previous
* comment, I create a newline in the IntelliSense bubble.
* @property {string} propString I don't describe `MyType`. I describe `propString`.
* I also describe `propString`.
*
*
*
* All the way down here, I am also a description for `propString`.
* @property {number} propLast I describe `propLast`.
*
*
*
* From now on, `propLast` calls the shots.
*/
So @interface
could probably have a similar rule. If I understand correctly, TypeScript interface
s really only describe properties and methods. The @typedef
tag uses @property
/@prop
to indicate that all of the following comments belong to that new property name. So perhaps in a similar fashion, TS could use something like @function
or @method
to indicate that all of the following comments belong to the new method name.
One thing that I don't favor about @typedef
is that just about any tag defines the "stopping point" for the previous property name. I don't think that has to be the case for @interface
. Instead, only the "important tags" (like @property
and @method
) need to create new starting/finishing points. That way, things like @param
and @returns
can just be associated with the most recent "important tag".
/**
* I describe `MyInterface` (if I'm the only description).
* @interface MyInterface I also describe `MyInterface`.
*
* Indeed. I, too, describe `MyInterface`.
* @property {string} propString I describe `propString`.
*
*
*
* I also describe `propString`.
* @method myMethod I describe `myMethod`.
* @param {number} firstArgument I describe `firstArgument`, within `myMethod`.
*
* I also describe `firstArgument`.
* @returns {void} I am telling you what `myMethod` returns.
*
* @example
* // I am an example belonging to `myMethod`.
* const variableInExample = "5";
*
* @property {boolean} propBoolean I create a new "scope" for descriptions. So from now on, all
* comments and "non-important tags" belong to me.
*
* For instance, even though a @see is here, it is still part of the description for `propBoolean`.
*/
I'm not sure how possible this is. But if it is possible, I think this would make sense. It's pretty similar to the @typedef
syntax, making it familiar. But it's also more powerful -- allowing for the description of methods, and allowing for things like @example
and @link
to be associated with those methods as well (just like in a regular interface
definition in a .ts
file). This syntax is also pretty similar to the regular TS syntax for interface
s, I think. It's just slightly more verbose (as to be expected in JSDocs). As long as TS wouldn't have a problem scoping "non-important" tags (like @see
, @param
, and @returns
) to the most recent "important tag" (like @property
or @method
), then I think this could work.
I'm sure alternatives are also possible. Thoughts?
Something like an @export
tag would be fantastic! I wasn't aware that the TS team was open to this idea. But it would certainly be helpful for concealing types that should not be exposed to end users (like internal, intermediate types).
Thankfully, no compilation step is required during development. Unfortunately, production release is a different story. When a user imports from a .js
file in node_modules
, TypeScript doesn't seem to recognize the JSDoc types provided in the .js
file. All of the information is there, but it is ignored. So the maintainer still has to produce .d.ts
files for the user to see/experience the desired types.
Consequently, my project has a build step. Because the .js
files are the source of truth in my project, and because I don't want the .d.ts
files confusing TypeScript during local development, my build process looks something like this:
.d.ts
, .d.cts
, .cjs
).d.ts
files for @my-scope/core
via tsc
. (This is so that the integration packages in the monorepo can use the types in core
. Currently, it seems easier to do this than to use references
.).d.ts
files for @my-scope/<FRAMEWORK>
via tsc
..js
files, this means converting all imports and exports to be compatible with CJS (require
/module.exports
) in a copied file. Similarly, for the generated .d.ts
files, this means converting all imports from .js
/.d.ts
to imports from .cjs
/.d.cts
in a copied file (since TS requires distinct types for modules and CJS).Because the generated .d.ts
files confuse TypeScript during development, Step 1 is its own script that I can run when I'm done with the release process.
If a project is only using JS (and perhaps some accompanying, non-compiled .d.ts
files that happen to be necessary/useful), then ideally no build step would be required at all. That's what I was hoping for when I rewrote the library that I'm currently working on to JSDocs. (I've kept the codebase in JSDocs land because it still seems beneficial to do so.)
Currently, as long as I'm writing code locally (in VS Code) or running tests, the type support is great! TypeScript throws errors when I break the types, and I get all the IntelliSense that I need to know what's going on. (There's also the bonus of avoiding type-related bugs that come from compilation, as in the case of generic constructors and method overloads.) It's the node_modules
part that doesn't quite work as desired, as I described earlier.
It would be amazing if TypeScript could read the types from the .js
files not only during development, but also when importing from node_modules
. My build step would basically go away entirely (minus the generation of .cjs
files, which isn't related to TS). So the maintenance of my project would get much easier.
That's really helpful to know! That is indeed my ideal. :sweat_smile: Being able to use pure JS to skip the build step would be really helpful. It would also help with debugging when end users run into problems (because the source -- including types -- would be in node_modules
, and people could test their own changes/fixes very easily).
I would like to re-open an issue: https://github.com/microsoft/TypeScript/issues/16142
As it is locked I am to open this new issue.
I personally do not like TypeScript as a language, I do however, like TypeScript as a tool for my JavaScript projects, hence why I would like more support for JSDoc.
Code
In a JavaScript file, with checkJs option :
Actual behavior:
No error.
Expected behavior:
An error should be raised. It's important in checkJs mode as JavaScript doesn't have native interfaces.