Introduction
Great documentation relies on an unbroken chain of references. As I'm responsible for the API Markdown Generation Phase, one of my tasks was to import internal types and interfaces that were missing from the generated documentation because they weren't explicitly exported.
The initial thought was simple: drop in typedoc-plugin-missing-exports and let it resolve the missing links. However, integrating a plugin into a massive codebase is rarely a plug-and-play operation. It turned into an architectural challenge involving Abstract Syntax Tree (AST) manipulation, memory optimization, and avoiding recursive explosions.
The Complexity of Resolution: 3 Experiments
To understand how TypeDoc handled these missing exports, I isolated the plugin's behavior through three distinct experiments:
Experiment 1: The Shallow Pass
Running the plugin with its default configuration recovered ~135 types. The downside? It isolated them in an <internal> module. Our documentation tool, nodejs/doc-kit, created markdown files for them and dumped them into an <internal> folder. We still needed to categorize them manually. Furthermore, TypeDoc didn’t traverse them deeply, leaving the documentation structurally inaccurate. It also included generic Node/JavaScript environment types, which we don't want since we aren't building Node/JS docs.
Experiment 2: Filtering the Noise (The Illusion of 60 Types)
By enabling excludeExternals: true, It stripped out the generic Node/JavaScript environment types. On the surface, this seemed successful, reducing the rendered payload in the docs to ~60 Webpack-specific types.
However, this was an illusion. In memory, the plugin was still extracting ~600 types and trapping them in the <internal> namespace. TypeDoc only rendered 60 of them because of its rendering and visibility rules, ignoring the deeper and nested dependencies. The rest were just eating up memory.
Experiment 3: The Recursive Explosion
To fix the shallow pass and map the types back to their original subsystems, I used placeInternalsInOwningModule: true. This broke the types out of the <internal>, but resulted in a recursive chain. The plugin began extracting nested internal interfaces, iterating continuously until it rendered ~630 inline types. The output was completely flooded with noise, making the developer experience terrible.
The Code Review: Rethinking the Architecture
I initially submitted a Pull Request based on Experiment 2, accepting the <internal> folder as necessary to avoid the recursive explosion of Experiment 3. But during the code review, the maintainers pointed out a fundamental flaw: keeping types in an <internal> module broke the logical grouping of Webpack's subsystems.
We needed the types in their respective modules, but we couldn't afford the ~600 noise types. We needed a custom intervention.
The Solution: Early Garbage Collection via AST Hooks
Instead of allowing TypeDoc to build an enormous AST and then trying to filter it during the routing phase (which wastes memory and CPU cycles), I implemented what I call "Early Garbage Collection" (similar to the V8 engine's Garbage Collector 😅).
By hooking directly into Converter.EVENT_RESOLVE_END, I could intercept the AST right after the initial resolution but before TypeDoc began assigning categories, building URLs, or allocating significant memory for the final output.
The logic was clean and focused on Separation of Concerns:
- Locate the
<internal>module in the AST (which secretly held all ~600 raw types). - Iterate through its child nodes.
- Evaluate each node using a custom
categoryForReflectionutility. -
Destroy the noise: Use
project.removeReflection(child)to immediately sever the references of over 300+ unneeded nodes, allowing Node.js to garbage collect them and free up memory. -
Lift the essentials: Merge the remaining ~300 crucial types directly into the root scope using
project.mergeReflections.
By doing this, I rescued ~240 vital types that were previously hidden in Experiment 2's shallow pass, bringing the total of correctly routed, fully visible types to ~300 all without the recursive noise of Experiment 3!
The Implementation:
app.converter.on(Converter.EVENT_RESOLVE_END, context => {
const project = context.project;
const internalModule = project.children?.find(c => c.name === '<internal>');
if (internalModule) {
const importantTypes = [];
internalModule.children.forEach(child => {
// Evaluate before routing to prevent memory waste
if (categoryForReflection(child)) {
importantTypes.push(child);
} else {
// Early Garbage Collection
project.removeReflection(child);
}
});
internalModule.children = importantTypes;
project.mergeReflections(internalModule, project); // Lift to root scope
}
});
What I Learned
Stepping back from the code, here are the core engineering lessons from this PR:
- Handle ASTs Early: "Garbage Collection" isn't just a V8 background task. Actively dropping unneeded AST nodes prevents massive downstream memory overhead.
-
Hooks over Hacks: Understanding compiler lifecycles (like
EVENT_RESOLVE_END) allows for clean interventions rather than messy downstream workarounds. - Reviews Shape Architecture: Maintainer feedback shifted my focus from simply "closing the issue" to building a structurally sound solution that fits the system.
- Completeness ≠ Usability: Extracting 600+ types is just noise. Good engineering is finding the sweet spot between capacity and actual developer experience.
Conclusion
What started as a simple plugin integration evolved into a lesson on compiler hooks and AST lifecycle management. By shifting the filtering logic earlier in the execution pipeline, we prevented memory overhead, exposed the true required types, avoided a recursive cascade of useless noise, and delivered clean, deeply-linked documentation for the Webpack community.
Top comments (0)