microsoft / TypeScript

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

JSDoc : support @interface and @implements (and more) #41675

Open basickarl opened 3 years ago

basickarl commented 3 years ago

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 :

/**
 * @interface Something
 */

/**
 * @function
 * @name Something#hello
 * @param {string} name
 */

/**
 * @implements {Something}
 */
const something = {};

Actual behavior:

No error.

Expected behavior:

An error should be raised. It's important in checkJs mode as JavaScript doesn't have native interfaces.

Binote commented 2 years ago

I also really need '@interface' support, I expect to use type federation in JS to support multiple type merges (based on JSDoc)

ITenthusiasm commented 1 year ago

Potential Use Cases for Interfaces in JSDocs

Pinging @sandersn as requested in #42877.

Extending Interfaces That an Application or Library Does Not Own

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 @typedefs, not interfaces. 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.

Extensive Documentation for Methods on an Interface

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.

What's Wrong with .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:

Inconsistent User Experience

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 @typedefs 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?

File Structure Bloat for Maintainers

For every JS file that can't be typed with JSDocs alone, a new file has to get added to the project. Extendable interfaces 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 Maintainers

The 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.

Additional Comments

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.

sandersn commented 1 year ago

Some initial comments and questions.

  1. Can you propose a syntax for @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.
  2. Your scenario also needs an @export tag for exporting types, right? At least it seems like it would make life a lot easier. Proposed in #48104 and #46011
  3. What kind of compile step do you have (or want) in your project? Are you able to run, debug and test without compiling? Does compiling invoke tsc to emit .d.ts from .js files? Do you want it to?
  4. Overall, I design JSDoc support with a pure-JS scenario in mind. There's a lot of design work needed, not just for individual tags, but also to make sure that the whole scenario can work smoothly end-to-end.
ITenthusiasm commented 1 year ago

1) Potential Syntax for Interfaces

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 interfaces 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 interfaces, 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?

2) Controlled Exposure of JSDoc Types

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).

3) Compilation or No Compilation?

Current Experience: Compilation Required for Production Release, but not during Development

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:

  1. Delete all the auto-generated files (.d.ts, .d.cts, .cjs)
  2. Generate the .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.)
  3. Generate the .d.ts files for @my-scope/<FRAMEWORK> via tsc.
  4. Generate the CJS files. For the .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.

Desired Experience: No Required Build Step at All

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.

4) TS Designs Supporting PureJS Scenarios

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).