-
Notifications
You must be signed in to change notification settings - Fork 609
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
[api-extractor] Better support for handling unexported base classes #3430
Comments
The Consider this hypothetical example (with these declarations buried throughout a big pile of source files): interface ILinkManagerOptions { . . . }
class LinkManager {
public constructor(options: ILinkManagerOptions);
. . .
}
class Link {
public constructor(manager: LinkManager);
public get manager(): LinkManager;
. . .
}
/** @public */
export class Document {
private _createLink(): Link;
} The trouble starts when somebody decides to promote Above, I am conflating several things:
Certainly we can consider decoupling these things. |
Option 1 can only work in very narrow cases. We could approach it using @partOf: /** {@partOf B} */
class A {
public f(): void;
}
/** @public */
export class B extends A {
public g(): void;
} ...with a bunch of constraints, for example we probably would forbid multiple copies: /** {@partOf B} {@partOf C} */
class A {
public f(): void;
}
/** @public */
export class B extends A {
public g(): void;
}
/** @public */
export class C extends A {
public g(): void;
} Option 1 seems like a hacky solution with limited usefulness. |
Enumerating these use cases is probably the best way to straighten this out. Here are some ideas to get started: Use Case: A normal API that chooses to be anonymousThis is your original motivating example I think:
The most natural API docs representation would be to show The solution might be like this: // Tell API Extractor that we did not forget to export UserInputBase
// -- it was an intentional design decision.
/** @unexported */
interface UserInputBase { With the Use Case: Bulk-ignoring a complex object// a sprawling set of interfaces describing a big JSON object
interface IThingJson {
title: IThingTitleJson;
body: IThingBodyJson;
owner: IThingOwnerJson;
detail: IThingDetailJson;
. . .
}
/** @public */
export class Thing {
/** @internal */
public constructor Thing(json: IThingJson);
} In this example, the constructor is marked as The solution might be like this: // Tell API Extractor that we did not forget to export IThingJson
// -- it was an intentional design decision.
// Also tell API Extractor not to generate an .api.json entry for this type.
/** @unexported @excludeFromDocs */
interface IThingJson { And maybe Use Case: An uninteresting auxiliary typeinterface IJsonObject {
[key: string]: JsonSerializable;
}
/** @public */
export type JsonSerializable = IJsonObject | number | string | Array<IJsonObject | number | string>; In this (hypothetical and not recommended😁) example, The solution might be like this: // Tell API Extractor to lump these two snippets together into a single API item
/** {@partOf JsonSerializable} */
interface IJsonObject { The rendered API docs would look like:
|
🤔 Here's a possible wrinkle for the internal-file.ts /** @unexported */
export class Pathological { } index.ts (entry point) import { Pathological as Pathological2 } from './internal-file';
/** @public */
export class Pathological extends Pathological2 { } In this case, API Extractor considers the official name of By this reasoning, |
Thanks for the thorough comments! Some responses below: Re your first comment: For the record, I definitely see the value of
I don't quite follow why Option 1 only works in very narrow cases or how using
I'll share some thoughts on each of the use cases you described: Use Case: A normal API that chooses to be anonymous
I'm not convinced this is the case. An API consumer won't know that
I'm not convinced this would be confusing if these members were flagged as "inherited". In general, I feel like we're approaching this example from two different angles. You see the relationship between My concern with the proposed solution (i.e. Use Case: Bulk-ignoring a complex objectIf the constructor is marked as Use Case: An uninteresting auxiliary typeAs I mentioned in my first comment, I'm comfortable not handling any type algebra cases for now. But assuming we did want to address it, the This feels like one of those cases where I think API Extractor might be overzealous in its |
Won't
In monorepos using 🤔 The lint ruleset requires explicit type declarations: import { Document, RichTextBase } from 'example-lib';
// @rushstack/eslint-config requires declarations like this:
function f(document: Document): RichTextBase {
return document.generateSummary();
}
// NOT LIKE THIS, because an unfamiliar person who is reading this
// code snippet cannot easily guess what kind of object is returned:
function f(document: Document) {
return document.generateSummary();
} Thus if the
I think it's the same situation actually -- regardless of whether If your company's code base has different conventions, it's fine to introduce an |
You're right. Let me try to rephrase my objection to "the lack of exporting can be easily thwarted by doing something like If the API consumer is trying to capture the unexported class hierarchy by writing this type alias, that's not a great idea, as there's nothing preventing the library from changing under the consumer's feet and making Suppose
It's up to the API author to decide whether they want to support consumers writing functions that accept or return This doesn't apply to other instances of
If a project has explicit type declarations turned on, they can't write any of the following:
That seems extremely limiting. I think there's a difference between the
I think this is on the right track. If an API accepts & returns values that cannot be typed, that seems like bad design (is there ever an instance where you'd want this?). What would it look like if we simply changed the |
In the projects that I've worked on, I think what would happen is that people would still forget, and in some cases it would still create problems. ("Why didn't
I agree. I'm just suggesting that this distinction should maybe be explicit and managed by people, rather than an implicit rule. Keep in mind that most "forgotten exports" actually have the word Control.ts // ae-forgotten-export
export class Control { } Button.ts import { Control } from './Control';
export class Button extends Control { } index.ts (entry point) export * from './Button'; It is easy to forget to export Do you find it cumbersome to have to suppress each warning by adding an explicit TSDoc tag?
|
To summarize, Option 1 is proposing that:
BEFORE this feature, the page URLs might be like this:
AFTER this feature, the page URLs would look like this:
Is this what you meant? Note that these URLs will break if we later change Note that we don't necessarily need to duplicate the entries in the |
Gotcha, this is helpful to understand. I think perhaps the decision comes down to…
What are other factors? For 1: If we think most of the time a developer knows what they're doing and is making a purposeful decision to not export a base class, then it seems counterproductive for them to need to explicitly suppress the I'm not sure what the right answer is here. My hunch is that in most cases, if developers get this warning for base classes, they decide they don't really care whether the base class is exported or not, and just end up exporting it to suppress the warning. I suppose I tend to be cautiously optimistic and assume that developers know what they're doing, unless they express otherwise. For 2: If the wrong behavior is very bad for an API, then we should be willing to accept a higher false positive rate to avoid the wrong behavior (and vice versa). I don't think the wrong behavior is necessarily any worse than forgetting to export any declaration. And we don't flag every unexported declaration as a potentially "forgotten export", so the wrong behavior doesn't seem particularly bad. For 3: If it's easy to suppress the warning, then we should be willing to accept a higher false positive rate (and vice versa). Agreed that TSDoc tags are an easy suppression mechanism… though an Option 1 discussionYes, your understanding is mostly correct. Only difference: Option 1 does not discuss any TSDoc
Yes - this is essentially I think the proposal in Option 2 (although again, Option 2 does not discuss any |
When that feature is turned on, what do the URLs look like? |
I was thinking the following for Option 1:
Option 1 is somewhat limiting as it means that |
https://docs.microsoft.com/en-us/dotnet/api/system.windows.controls.button?view=windowsdesktop-6.0 ...but when you click on the hyperlink for the property, it takes you to the In other words, the #3429 inheritance is more like a viewing aid, that doesn't alter the basic page URLs or model. Whereas the #3430 Fluent UI has very brief documentation for each property, so they just put everything on one page, which sidesteps the problem of duplicated URLs: https://developer.microsoft.com/en-us/fluentui#/controls/web/button#IButtonProps |
Our primary use case right now is the same as Fluent's. We have a single documentation page for every component in our component library. So, yeah, the whole URL discussion isn't particularly important to use at the moment. Regardless, after some additional thought, I think I strictly prefer Option 2 to Option 1. Primary reasons being:
|
Quick note: Another real-world example of unexported base classes is the mixin pattern used extensively within api-extractor-model. |
After a long discussion between @octogonz and myself today, we concluded that API Extractor should no longer treat base classes that are only "reachable" via inheritance as "forgotten exports". That means that
but will still be a forgotten export in this scenario:
The docs page for
In scenario 1, the exported API does not refer to This conclusion means the following:
@octogonz - please let me know if I've misrepresented/misunderstood anything. |
I have hit this issue now in two projects I am working on. For the first project, I actually ended up writing a post-processor that cleaned up the API generated by API Extractor (https://github.com/firebase/firebase-js-sdk/tree/master/repo-scripts/prune-dts). This script and its integration with API Extractor adds two distinct features:
becomes:
I understand that this is probably not in the spirit of this project, but I have since started working on a new project and am hitting the exact same limitations again. I would like to have a clean, user-facing API that doesn't reveal my internal code structure. For the new project, I am likely moving away from inheritance and will instead use a delegate. I am super excited to see the work on this issue and hope that we can resolve it soon. |
We've encountered the same problem when utilizing api-extractor for api reports. Assuming we have to packages and the interface of one package
With |
Summary
The purpose of this issue is to kick off a discussion for how API Extractor can provide better support for handling unexported base classes. Consider the following scenario:
In the case above,
UserInputBase
is an anonymous implementation detail ofButton
andCheckbox
and purposefully not exported.Today, API Extractor will generate documentation pages for
Button
andCheckbox
but not forUserInputBase
, as the latter isn't exported. Additionally, API Extractor will not generate documentation for any inherited members fromUserInputBase
within the documentation pages forButton
andCheckbox
. Lastly, it will surface anae-forgotten-export
warning forUserInputBase
.My understanding is that API Extractor treats the pattern above as "bad API design", hence the
ae-forgotten-export
warning and poor documentation support. While in some cases this may indeed be bad API design (e.g. if you know developers will want to write functions that operate onUserInputBase
objects), I think the false positive rate is too high. There are valid reasons to not exportUserInputBase
. For example, the API author might want the ability to refactor the inheritance chain of these classes without making a breaking change. The author might want to renameUserInputBase
, or remove it completely and move the inherited members directly into the sub-classes. These changes are harder to make ifUserInputBase
is exported.For these reasons, I think we should reconsider the following for this case:
ae-forgotten-export
warning should be surfaced.Note that all of the above applies to interface inheritance as well.
Details
ae-forgotten-export
warningI think the behavior here should be determined by the false positive rate for the
ae-forgotten-export
warning for this case:ae-forgotten-export
warning should never be surfaced.In my opinion, I think 1 or 2 make the most sense.
Better documentation support
Here are a few ideas for how API Extractor could provide better documentation support for this case. I don't have a super strong preference yet, I'll need some more time to think through them.
1. Include inherited members within exported sub class's API doc model
In this approach, we continue to omit the unexported base class from the API doc model and API report, but include any inherited members within the sub class's API doc model. So for the following scenario:
The API doc model would look like this:
If
B
was exported, then the API doc model would look like this:Open questions:
B
" from the API doc model entirely, assumingB
is truly just an implementation detail ofA
? Or renameB
to something like(Anonymous)
to indicate that its name is an unimportant? Or append something like(unexported)
afterB
?A
extendsB
extendsC
extendsD
, but onlyA
andC
are exported. I thinkA
needs to include all inherited members fromB
,C
, andD
(note that we need to include the inherited members fromC
andD
because there's nothing in the doc model indicating thatB
extendsC
). Alternatively, we could simply not support this case if we think it represents bad API design.B
is in another package? IfB
is in another package, then it must be exported (in order for it to be extended byA
), and thus it should be included in its package's API doc model. We shouldn't need to include any inherited members in this case.2. Include unexported base classes in the API doc model
In this approach, we simply include unexported base classes in the API doc model and API report. Thus, an exported class's entire inheritance chain is always included in the API doc model. So for the following scenario:
The API doc model would look like this:
Open questions:
B
is unexported?3. Add a configuration to include forgotten exports in the API doc model
This is a solution to the more general use case of wanting to include "forgotten exports" in the API doc model. This is also the approach described at #1235 (comment). We could add a new
includeForgottenExports
config to API Extractor that would cause all forgotten exports to be by default included in the API doc model and API report. We could also use some kind of doc comment tag to include/exclude declarations from these files at a per-declaration level.I'm not sure whether there's enough of a need for documenting forgotten exports other than the class/interface inheritance case. The only other example that comes to mind is something like this (see #1235 (comment)):
But I'm receptive to API Extractor's current stance of not spending too much time/effort on perfectly documenting arbitrary type algebra.
Open questions:
The text was updated successfully, but these errors were encountered: