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

Manually defining "Type Declarations" on a type alias #2032

Closed
lorisleiva opened this issue Aug 11, 2022 · 7 comments
Closed

Manually defining "Type Declarations" on a type alias #2032

lorisleiva opened this issue Aug 11, 2022 · 7 comments
Labels
plugin idea This feature may be best suited for a plugin question Question about functionality

Comments

@lorisleiva
Copy link

Search terms

type alias, type declarations, inherit properties

Question

Hi there 👋

I've got a type alias A that depends on a simple object type B (using Omit and adding a bunch of other properties) and I would like the type A to be as thoroughly documented as type B.

Here's a concrete example
  • In this example, type B is Metadata which is an object type with some documented properties.
    CleanShot 2022-08-11 at 19 48 57@2x
  • Then, the Nft type (type A in this analogy) uses most of the data of Metadata but removes some properties and add some new ones.
    CleanShot 2022-08-11 at 19 49 22@2x

Because type A is identified as a type alias, it doesn't get expanded (by design) but I would still like to add some tags on the docblocks to document it like type B.

I've not been able to find any documented tags that serve that purpose. Ideally, I would have a @property tag that would fill the "Type declaration" section of the type instead of adding a new "Property" block. Even better, I would have a way to link a property from another type and inherit the docblock of that property only.

I imagine there's no native way of achieving this right now but could I achieve this via a plugin?

I couldn't find much literature on how to create plugins so I would appreciate some pointers here.

Thank you for taking the time to read this. 🌺

@lorisleiva lorisleiva added the question Question about functionality label Aug 11, 2022
@Gerrit0
Copy link
Collaborator

Gerrit0 commented Aug 13, 2022

Yep, you're correct there's no builtin way to do this today. There aren't really any plugins which do something like this either.... a first pass could look something like:

// CC0
// @ts-check
const td = require("typedoc");

/** @param {td.Application} app */
exports.load = function (app) {
    app.converter.on(td.Converter.EVENT_CREATE_DECLARATION, replaceObjectLikeTypes);
};

/**
 * @param {td.Context} context
 * @param {td.DeclarationReflection} reflection
 */
function replaceObjectLikeTypes(context, reflection) {
    if (reflection.kind !== td.ReflectionKind.TypeAlias) return;

    const propTags = reflection.comment?.getTags("@property");
    if (!propTags?.length) return;

    const symbol = context.project.getSymbolFromReflection(reflection);
    const declaration = symbol?.declarations?.[0];
    if (!symbol || !declaration) return; // Will probably never happen in practice
    const reflectionType = context.checker.getDeclaredTypeOfSymbol(symbol);

    const typeRefl = context
        .withScope(reflection)
        .createDeclarationReflection(td.ReflectionKind.TypeLiteral, undefined, undefined, "__type");
    context.finalizeDeclarationReflection(typeRefl);

    const typeContext = context.withScope(typeRefl);

    for (const tag of propTags) {
        if (!tag.name) {
            context.logger.warn(`@property tag missing a name in ${reflection.getFriendlyFullName()}`);
            continue;
        }

        const memberType = reflectionType?.getProperty(tag.name);
        if (!memberType) {
            context.logger.warn(
                `${reflection.getFriendlyFullName()} specified '@property ${
                    tag.name
                }', but does not have a property called '${tag.name}', will be documented as any.`
            );
        }

        const element = typeContext.createDeclarationReflection(
            td.ReflectionKind.Property,
            undefined,
            undefined,
            tag.name
        );
        element.comment = new td.Comment(tag.content);
        element.type = typeContext.converter.convertType(
            typeContext,
            memberType && context.checker.getTypeOfSymbolAtLocation(memberType, declaration)
        );
        typeContext.finalizeDeclarationReflection(element);
    }

    reflection.comment?.removeTags("@property");
    reflection.type = new td.ReflectionType(typeRefl);
}

When running with:

typedoc src/gh2032.ts --plugin ./plugins/prop.js 
/**
 * Foo docs
 */
export type Foo = {
    /**
     * Foo.a docs
     */
    a: 123;
    /**
     * Foo.b docs
     */
    b: 456;
};

/**
 * Bar docs
 * @property a docs
 * @property b docs
 * @property c could lie
 */
export type Bar = {
    [K in keyof Foo]: string;
};

Prints:

info Loaded plugin C:\Users\gtbir\Desktop\master-tests\plugins\prop.js
warning Bar specified '@property c', but does not have a property called 'c', will be documented as any.
info Documentation generated at ./docs

@Gerrit0 Gerrit0 added the plugin idea This feature may be best suited for a plugin label Aug 13, 2022
@lorisleiva
Copy link
Author

That's brilliant, thank you! I'll spend some time playing with this plugin to learn how it all works. 🍺

@lorisleiva
Copy link
Author

lorisleiva commented Aug 17, 2022

Hiya 👋

Just wanted to share my updated solution here.

I ended up going with a more dynamic approach that automatically expands type aliases of types mapped, intersection and reference by fetching both the resolved type and the resolved reflection.

Here's a simple example.

// Input
export type Animal = {
  /** The animal's name. */
  name: string;

  /** The animal's number of eyes. */
  eyes: number;
}

export type Bird = Animal & {
  /** The length of the bird's wings. */
  wingspan: number;
}

// Output
export type Bird = {
  /** The animal's name. */
  name: string;

  /** The animal's number of eyes. */
  eyes: number;

  /** The length of the bird's wings. */
  wingspan: number;
}

Here's a more complex example.

// Input
export type Animal = {
  /** The animal's name. */
  name: string;

  /**
   * The animal's number of eyes.
   * @defaultValue `2`
   */
  eyes: number;
};

export type Messenger = {
  /** The company used to send the message. */
  company: string;

  /** Send a message. */
  send(message: string): void;
};

export type Bird = Partial<Animal> &
  Omit<Messenger, 'company'> & {
    /** The length of the bird's wings. */
    wingspan: number;
  };

// Output
export type Bird = {
  /** The animal's name. */
  name?: string;

  /**
   * The animal's number of eyes.
   * @defaultValue `2`
   */
  eyes?: number;

  /** The length of the bird's wings. */
  wingspan: number;

  /** Send a message. */
  send(message: string): void;
}

And here's my implementation.

// CC0
// @ts-check
const td = require('typedoc');

/** @param {td.Application} app */
exports.load = function (app) {
  app.converter.on(
    td.Converter.EVENT_CREATE_DECLARATION,
    expandObjectLikeTypes
  );
};

/**
 * The reflection types affected by this plugin.
 */
const TYPES_TO_EXPAND = ['mapped', 'intersection', 'reference'];

/**
 * @param {td.Context} context
 * @param {td.DeclarationReflection} reflection
 */
function expandObjectLikeTypes(context, reflection) {
  if (
    reflection.kind !== td.ReflectionKind.TypeAlias ||
    !reflection.type?.type ||
    !TYPES_TO_EXPAND.includes(reflection.type.type)
  )
    return;

  const symbol = context.project.getSymbolFromReflection(reflection);
  const declaration = symbol?.declarations?.[0];

  if (!symbol || !declaration) return; // Will probably never happen in practice
  const reflectionType = context.checker.getDeclaredTypeOfSymbol(symbol);

  const typeRefl = context
    .withScope(reflection)
    .createDeclarationReflection(
      td.ReflectionKind.TypeLiteral,
      undefined,
      undefined,
      '__type'
    );
  context.finalizeDeclarationReflection(typeRefl);
  const typeContext = context.withScope(typeRefl);

  for (const propertySymbol of reflectionType.getProperties()) {
    const propertyType =
      propertySymbol &&
      context.checker.getTypeOfSymbolAtLocation(propertySymbol, declaration);
    const resolvedReflection = resolvePropertyReflection(
      context,
      reflectionType,
      propertySymbol
    );

    const element = typeContext.createDeclarationReflection(
      td.ReflectionKind.Property,
      undefined,
      undefined,
      propertySymbol.name
    );

    if (resolvedReflection) {
      element.comment = resolvedReflection.comment;
      element.flags = resolvedReflection.flags;
      element.sources = resolvedReflection.sources;
      element.url = resolvedReflection.url;
      element.anchor = resolvedReflection.anchor;
      element.cssClasses = resolvedReflection.cssClasses;

      if (resolvedReflection instanceof td.DeclarationReflection) {
        element.defaultValue = resolvedReflection.defaultValue;
      }
    }

    element.type = typeContext.converter.convertType(typeContext, propertyType);
    typeContext.finalizeDeclarationReflection(element);
  }

  reflection.type = new td.ReflectionType(typeRefl);
}

/**
 * @param {td.Context} context
 * @param {td.TypeScript.Type} objectType
 * @param {td.TypeScript.Symbol} propertySymbol
 */
function resolvePropertyReflection(context, objectType, propertySymbol) {
  const resolvedType = context.checker.getPropertyOfType(
    objectType,
    propertySymbol.name
  );
  const resolvedDeclaration = resolvedType?.declarations?.[0];
  const resolvedSymbol =
    resolvedDeclaration && context.getSymbolAtLocation(resolvedDeclaration);

  return (
    resolvedSymbol && context.project.getReflectionFromSymbol(resolvedSymbol)
  );
}

Thank you for engineering this really powerful plugin system.

I hope this helps someone and please don't hesitate to let me know if there's a simpler way of achieving this. 😊

@Gerrit0
Copy link
Collaborator

Gerrit0 commented Aug 19, 2022

element.url = resolvedReflection.url;
element.anchor = resolvedReflection.anchor;
element.cssClasses = resolvedReflection.cssClasses;

There's no point in doing this at this point, these haven't been set yet. (sources may have been set, I can never remember how event listeners get added, they've probably been set)

if (resolvedReflection instanceof td.DeclarationReflection) {
  element.defaultValue = resolvedReflection.defaultValue;
}

Seems kind of weird to me to do this, since type aliases live only in type space and therefore can't have "real" default values, but to each their own.

It's also worth mentioning that this plugin may produce different results for:

export const x = { a: 1 }
export type Y = { [K in keyof typeof x]: string }
// vs
export type Y = { [K in keyof typeof x]: string }
export const x = { a: 1 }

You could fix this by deferring the type replacement until Converter.EVENT_RESOLVE_BEGIN and replacing the types at that point if that causes issues. You'd have to do something similar to typedoc-plugin-missing-exports though, since TypeDoc doesn't have context.checker at that point.

@lorisleiva
Copy link
Author

Hi, thanks for the tips!

Good shout, the defaultValue is definitely more application-specific but it was needed in my case.

FYI I've tried the provided examples and they both produced the same expected result.

@gnidan
Copy link

gnidan commented Aug 25, 2022

I know the concern raised by this issue has been plaguing you @Gerrit0 for at least a year or two now... maybe there's a workaround that would be easier to implement?

I understand that an automated type resolution system would be most ideal for this use case... but what about leaning into this issue's use of the word "manually" in the title? I'm thinking something like:

/**
 * @param x DocsFriendlyX - input to function
 */
export function foo(x: X) {
  /* ... */
}

// elsewhere
export type X = /* ... something complicated to look at ... */;
export type DocsFriendlyX = /* ... something not as complicated to look at ... */

No idea if this would be easier, but it seems relevant to bring up here... like, TypeDoc already knows all the references everywhere; seems like all that would be necessary here is a way to tell TypeDoc to forcibly override one type with another.

Just a thought! Thanks!

(quick edit: re-reading through this, it just dawned on me that maybe what I'm talking about here warrants a new issue. please advise if you'd like me to open this separately!)

@Gerrit0
Copy link
Collaborator

Gerrit0 commented Aug 31, 2022

FYI I've tried the provided examples and they both produced the same expected result.

Huh, I thought ts gave exports in declaration order... must be more complicated than that. Might need to move one or the other into a separate file and re-export so that TypeDoc converts them in a specific order. (Direct exports are converted first)

is a way to tell TypeDoc to forcibly override one type with another.

Definitely a different feature request - and not something I'm likely to go for. TypeDoc is meant to document the API visible to your users, closely matching what they'll see in declaration files when actually using the library. If your exported API is so complicated that displaying that to a user isn't feasible... that might be an indication that a simpler design is warranted.. I would probably include a comment on X that uses {@link DocsFriendlyX} to point users to the simpler version... You could write a plugin for the override as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
plugin idea This feature may be best suited for a plugin question Question about functionality
Projects
None yet
Development

No branches or pull requests

3 participants