microsoft / TypeScript

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

JSDoc equivalent of import * #41825

Closed Gozala closed 6 months ago

Gozala commented 3 years ago

Search Terms

Provide a JSDoc equivalent of import * as Sub from "./sub" so that types & interfaces from the ./sub module could be referenced as Sub.Type, Sub.Interface.

I have no preference in terms of actual syntax, but in my experience general expectation had been that following syntax should do just that (but it is not and I can't seem to figure out what does Sub results in here:

@typedef {import('./sub')} Sub

If above is not a viable options maybe using same * character could be an options:

@typedef {import('./sub').*} Sub

Or maybe whole new @import jsdoc tag.

Use Cases

js-ipfs (fairly large code base) has adopted TS via JSDoc syntax but dealing with large number of imported types & interfaces from other modules had been a major source of pain for following reasons:

  1. Every single type requires @typedef {import('...').Name} Alias.
  2. Importing generic types / interfaces requires repeating type parameters via @template tags
    • Which is error prone because when omitted it turns into any
    • Ends up require lot of typing
  3. Changes to library layout requires updating all those typdefs all over the code base (and vscode can't help there sadly)
    • Alternative is to consolidating them in one place, but then vscode (and alike) takes multiple hops / clicks to get you to actual type definition.

All of the above make otherwise mostly good experience to be painful.

Current approach also has side effect of turning imported interfaces into types (see #41258) which than can't be used in implements syntax as they are no longer interfaces.

Given that it is possible to do import * as Sub from "./sub" to get a namespace of exports in TS syntax it seems like equivalent in jsdoc syntax would:

  1. Allow reduced number of @typedef {import(...).Name} Name declarations.
  2. Allow importing generic types without having to retype type parameters via @temaplate tags.
  3. Avoid turning interfaces.

Examples

Consider following TS code:

import { BlockEncoder, BlockDecoder } from "@multiformats/codecs"
import { DagNode } form "./node"

class DagPB implements BlockEncoder<0x70, DagNode>, BlockDecoder<0x70, DagNode> {
  async encode(node:DagNode):Promise<Uint8Array> {
     // ...
  }
  async decode(bytes:Uint8Array):Promise<DagNode> {
    // ...
  }
}

Same code with JSDoc syntax turns into following:

/**
 * @template {number} Code
 * @template T
 * @typedef {import('@multiformats/codecs').BlockEncoder<Code, T>} BlockEncoder
 */
/**
 * @template {number} Code
 * @template T
 * @typedef {import('@multiformats/codecs').BlockDecoder<Code, T>} BlockDecoder
 */
/**
  * @typedef {import("./node").DagNode} DagNode
  */

/**
 * @implements {BlockEncoder<0x70, DagNode>}
 * @implements {BlockDecoder<0x70, DagNode>}
 */
class DagPB {
  /**
   * @param {DagNode} node
   * @returns {Promise<Uint8Array>}
   */
  async encode(node) {
     // ...
  }
  /**
   * @param {Uint8Array} bytes
   * @returns {Promise<DagNode>}
   */
  async decode(bytes) {
    // ...
  }
}

Note: Above code won't even produce desired typedefs due to #41258

And now if we had what this issue proposes it would be:

/**
 * @typedef {import('@multiformats/codecs')} Codec
 * @typedef {import('./node').DagNode} DagNode
 */

/**
 * @implements {Codec.BlockEncoder<0x70, DagNode>}
 * @implements {Codec.BlockDecoder<0x70, DagNode>}
 */
class DagPB {
  /**
   * @param {DagNode} node
   * @returns {Promise<Uint8Array>}
   */
  async encode(node) {
     // ...
  }
  /**
   * @param {Uint8Array} bytes
   * @returns {Promise<DagNode>}
   */
  async decode(bytes) {
    // ...
  }
}

Checklist

My suggestion meets these guidelines:

RyanCavanaugh commented 3 years ago

cc @sandersn for visibility

Raynos commented 3 years ago

Ran into a similar related issue today

I attempted to do the following

/** @typedef {import('../../types.js')} T */

/**
 * @return {Promise<T.Result<any>>}
 */
module.exports = async (args) => { ... }

Where types.js is

/**
 * @template T
 * @typedef {{
 *    err: Error,
 *    data?: undefined
 * } | {
 *    err?: undefined,
 *    data: T
 * }} Result<T>
 */

module.exports = {}

And then I got

'T' only refers to a type, but is being used as a namespace here.

I wanted to treat the entire package as a namespace so that I can import it and reference types inside of it using the import() function syntax, but it seems like that only returns types and not namespaces.

Raynos commented 3 years ago

@Gozala

Due to random dumb luck and just trying stuff I discovered that you can declare ... in a project ( https://github.com/voxpelli/types-in-js/blob/main/FAQ.md#i-want-to-share-some-helper-interfaces-or-aliases-globally-in-my-jsdoc-project ).

You may be able to declare that various types exists in @multiformats/codecs/declare.d.ts and then just include that in jsconfig / tsconfig so that those tokens are available in a global scope.

You can also use namespaces to namespace codecs { interface BlockEncoder }

This does not solve any of the importing use cases, however it turns out that the author whom writes the exports can export into a global namespace which can be used anywhere without import syntax.

Gozala commented 3 years ago

Poor mans solution I've settled on is along these lines:

lib.js (implementation lives here)

import * as API from "./result.js"
/**
 * @template T
 * @param {T} value
 * @returns {API.Result<never, T>}
 */
export ok = (value) => ({ ok: true, value })

result.js (just a facade for lib.js)

export * from "./lib.js"

result.ts (shadow .js to add types)

export * from "./lib.js"
export type Result<X, T> =
  | { ok: true, value: T }
  | { ok: false, error: X }

Now any other module could do following:

import * as Result form "../path/to/result.js"

/**
 * @template X, T
 * @param {Result.Result<X, T>} result
 * @param {(t:T) => U} f
 * @returns {Result.Result<X, U>}
 */
export const map = (result, f) =>
  result.ok ? Result.ok(f(result.value)) : result
Jamesernator commented 3 years ago

Just to clarify what the issue actually is, essentially this:

/**
  * @typedef {import("./someModule.js")} SomeModule
  */

is actually equivalent to this:

/**
  * @typedef {typeof import("./someModule.js")} SomeModule
  */

In TypeScript however the following are distinct in general:

import type * as SomeModule from "./someModule.js";
type SomeModule = typeof import("./someModule.js");
Gozala commented 2 years ago

Hey Team,

Any updates on this I keep introducing facade files just to workaround retyping all the generics at every import, but it really feels like there needs to be a proper solution. I also came across https://github.com/microsoft/TypeScript/issues/22160 which seems to be asking fro the same feature with different syntax.

Thaina commented 2 years ago

Not sure is it related but it seem the @typedef was not really registered a symbol as a type

This affect constructor that I can't see the signature and autocomplete on the constructor

Gozala commented 2 years ago

@RyanCavanaugh @sandersn any updates on this ? This is probably the most annoying limitation when using TS with JSDoc.

If someone is willing to mentor me on this, I’d be up to put my time into this.

Thaina commented 2 years ago

edit: It seem not the case I talk about in previous comment

My problem is actually @typedef is not working as expected

Using @type is what I expected

image

But @typedef didn't

image

Using @var also not the solution

image

toFrankie commented 1 year ago

mark πŸ‘€

lacherogwu commented 1 year ago

Looking forward

sandersn commented 1 year ago

We decided to accept this issue. Here's an outline of my thoughts for anybody that wants to try implementing this:

  1. Syntax:
    • @importType followed by whatever import syntax can follow import type in Typescript. For example, @importType { Component } from '@angular/core' or @importType * as Types from './types'
    • Like @typedef/@callback, this is a top-level, standalone tag.
    • Re-use the existing import parsing code as much as possible, because this will make the rest of the implementation smaller.
  2. Semantics: same as import type as much as possible. The checker already falls back to JSDoc for things like @type, @overload, @private, etc. The code for @importType will be similar, but there will be lots of places that need to change; imports are used all over.
  3. Specifically, the services layer will need lots of tests to make sure that find-all-refs, goto-def and everything else work with @importType the same as they do for import type. Auto-imports will be especially tricky. I have no idea how auto-imports work in JS right now, but this tag would theoretically provide feature parity; generate import for values and @importType for types.

That's very high level. I likely skimmed over lots of work in saying that the compiler needs to provide the same semantics as import type, so feel free to talk through followup issues here.

boneskull commented 1 year ago

@sandersn I'll buy you a sandwich when this lands

what1s1ove commented 10 months ago

Hope it will handle such cases with generics. Or maybe you know another way to handle it without importing type right in the label type key.

// values-of.type.js

/**
 * @template T
 * @typedef { T[keyof T] } ValuesOf
 */
// settings-button-payload.type.js

// error ValuesOf is a generic 
/** @typedef {import('../types/types.js').ValuesOf} ValuesOf */
/** @typedef {typeof import('../enums/enums.js').SettingButtonLabel} SettingButtonLabel */

/**
 * @typedef {{
 *  isDefaultChecked: boolean
 *  label: ValuesOf<SettingButtonLabel>
 * }} SettingsButtonPayload
 */
what1s1ove commented 9 months ago

Hope it will handle such cases with generics. Or maybe you know another way to handle it without importing type right in the label type key.

// values-of.type.js

/**
 * @template T
 * @typedef { T[keyof T] } ValuesOf
 */
// settings-button-payload.type.js

// error ValuesOf is a generic 
/** @typedef {import('../types/types.js').ValuesOf} ValuesOf */
/** @typedef {typeof import('../enums/enums.js').SettingButtonLabel} SettingButtonLabel */

/**
 * @typedef {{
 *    isDefaultChecked: boolean
 *    label: ValuesOf<SettingButtonLabel>
 * }} SettingsButtonPayload
 */

I just found a way to fix it by reading the 'The Saga of the Closure Compiler, and Why TypeScript Won' article. The method is quite specific. 🀯

// values-of.type.js

/**
 * @template T
 * @typedef {T[keyof T]}
 */
let ValuesOf

export { ValuesOf }
// settings-button-payload.type.js

import { ValuesOf } from '~/libs/types/types.js'

/** @typedef {typeof import('../enums/enums.js').SettingButtonLabel} SettingButtonLabel */

/**
 * @typedef {{
 *  isDefaultChecked: boolean
 *  label: ValuesOf<SettingButtonLabel>
 * }} SettingsButtonPayload
 */
remcohaszing commented 7 months ago

I addition to @importType, I think an analogous @exportType would be nice too.

Within unifiedjs we use a lot of re-exports to expose the public interface including types. An example is https://github.com/mdx-js/mdx/blob/3.0.1/packages/mdx/index.js.

This code is equivalent to:

export type Fragment = import('./lib/util/resolve-evaluate-options.js').Fragment
// etc

In TypeScript you would write this as:

export { Fragment } from './lib/util/resolve-evaluate-options.js'
// etc

This results in a loss of descriptions of those types for the users. Plus generics need to be redefined.