rushstack icon indicating copy to clipboard operation
rushstack copied to clipboard

[api-extractor] Keep separate d.ts files but filter public API

Open Danielku15 opened this issue 2 months ago • 2 comments

Summary

I'd like to use the API extractor functionalities to track my public API and filter anything non-public from my d.ts files.

At the same time I want to keep separate d.ts files (one for every .mjs just like typescript prints it).

Unfortunately the dtsRollup works in a "bundle/barrel module" mindset where you only have a single library file.

Details

In the era of ESM the practice of "bundling" when publishing packages seem to vanish to support features like tree-shaking better. Same applies for "barrel modules" which contain all exports of your library.

Following this strategy it would be great if api-extractor could do it's magic without expecting a single barrel d.ts.

Imagine a tsc output of .mjs and .d.ts files matching the .ts file hierarchy. I'd expect:

  1. api-extractor to crawl the modules of my project and build the public API surface.
  2. Potentially the exports property of the package.json is consulted to filter what's really public from an NPM perspective (mapping might be tricky though).
  3. api-extractor writes the output d.ts files with the same hierarchy to a given output directory (empty d.ts files are omitted).
  4. Any warnings on discrepancies of exports are reported (e.g. a public api exposes a filtered private one).

Standard questions

Please answer these questions to help us investigate your issue more quickly:

Question Answer
@microsoft/api-extractor version? 7.53.3
Operating system? Any
API Extractor scenario? reporting (.api.md) / rollups (.d.ts) / docs (.api.json)
Would you consider contributing a PR? Maybe, depends on the efforts
TypeScript compiler version? 5.9.3
Node.js version (node -v)? v24.11.0

Danielku15 avatar Nov 18 '25 21:11 Danielku15

Interesting idea. However, this would be pretty significantly different from how API Extractor currently works. @octogonz would have a better idea on the feasibility of this feature.

iclanton avatar Nov 18 '25 22:11 iclanton

I'm currently working on some PoC for my own project. It's very hack&dirty but it shows in which direction things should go. The nastiest part is the linking of imports and exports. Not sure what's the best way in the TS compiler API to check for symbol equality so I just combined the file path and name.

Expand me

import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
import { defineConfig, type LibraryOptions } from 'vite';

function createDiagnosticReporter(pretty?: boolean): ts.DiagnosticReporter {
    const host: ts.FormatDiagnosticsHost = {
        getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
        getNewLine: () => ts.sys.newLine,
        getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? x => x : x => x.toLowerCase()
    };

    if (!pretty) {
        return diagnostic => ts.sys.write(ts.formatDiagnostic(diagnostic, host));
    }

    return diagnostic => {
        ts.sys.write(ts.formatDiagnosticsWithColorAndContext([diagnostic], host) + host.getNewLine());
    };
}

function shouldFilter(node: ts.Node) {
    if (ts.canHaveModifiers(node) && node.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword)) {
        return true;
    }
    for (const tag of ts.getJSDocTags(ts.getOriginalNode(node))) {
        switch (tag.tagName.text) {
            case 'public':
                return false;
            case 'internal':
            case 'private':
                return true;
        }
    }
    return false;
}

function symbolFromNode(checker: ts.TypeChecker, node: ts.Node) {
    return ((node as any).symbol as ts.Symbol | undefined) || checker.getSymbolAtLocation(node);
}

function symbolKey(sourceFile: string, exportName: string) {
    return `${sourceFile}__${exportName}`;
}

function filterApiVisitor(checker: ts.TypeChecker, s: ts.SourceFile, removedSymbols: Set<string>) {
    return function visitor(node: ts.Node) {
        if (ts.canHaveModifiers(node) && node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)) {
            if (!shouldFilter(node)) {
                return ts.visitEachChild(node, visitor, undefined);
            }

            const name = 'name' in node ? (node.name?.getText() ?? '') : '';
            removedSymbols.add(symbolKey(s.fileName, name));
            return undefined;
        } else if (ts.isExportAssignment(node)) {
            if (!shouldFilter(node)) {
                return ts.visitEachChild(node, visitor, undefined);
            }

            removedSymbols.add(symbolKey(s.fileName, ''));
            return undefined;
        } else if (ts.isExportDeclaration(node)) {
            if (!shouldFilter(node)) {
                return ts.visitEachChild(node, visitor, undefined);
            }

            if (node.exportClause && ts.isNamedExports(node.exportClause)) {
                for (const exp of node.exportClause.elements) {
                    const symbol = symbolFromNode(checker, exp);
                    if (symbol) {
                        removedSymbols.add(symbolKey(s.fileName, exp.name.getText()));
                    }
                }
            }

            return undefined;
        } else if (shouldFilter(node)) {
            return undefined;
        }

        return ts.visitEachChild(node, visitor, undefined);
    };
}

const moduleFlags = ts.SymbolFlags.ValueModule | ts.SymbolFlags.NamespaceModule;
function tryGetSourceFile(checker: ts.TypeChecker, node: ts.ImportDeclaration) {
    const sourceFile = symbolFromNode(checker, node.moduleSpecifier);
    if (
        sourceFile &&
        (sourceFile.flags & moduleFlags) !== 0 &&
        sourceFile.valueDeclaration &&
        ts.isSourceFile(sourceFile.valueDeclaration)
    ) {
        return sourceFile.valueDeclaration;
    }
    return undefined;
}

function filterImportsVisitor(checker: ts.TypeChecker, s: ts.SourceFile, removedSymbols: Set<string>) {
    let importedSourceFile: string | undefined;
    return function visitor(node: ts.Node) {
        if (ts.isImportDeclaration(node) && node.importClause) {
            const sourceFile = tryGetSourceFile(checker, node);
            if (!sourceFile) {
                console.error('failed to determine module of import clause', node.getText());
                return node;
            }

            // import a from 'x'
            if (node.importClause.name) {
                const symbol = symbolKey(sourceFile.fileName, '');
                if (removedSymbols.has(symbol)) {
                    return undefined;
                }
            } else if (node.importClause.namedBindings) {
                // import * as x from 'x';
                if (ts.isNamespaceImport(node.importClause.namedBindings)) {
                    // cannot nicely filter these. hard to detect if all removed members are ununsed
                }
                // import { a, b, c as d} from 'x'
                else if (ts.isNamedImports(node.importClause.namedBindings)) {
                    importedSourceFile = sourceFile.fileName;
                    const rewritten = ts.visitEachChild(node, visitor, undefined);
                    importedSourceFile = undefined;

                    if ((rewritten.importClause!.namedBindings! as ts.NamedImports).elements.length === 0) {
                        return undefined;
                    }
                    return rewritten;
                }
            }
        } else if (ts.isImportSpecifier(node) && importedSourceFile) {
            const symbol = symbolKey(importedSourceFile, node.name.getText());
            if (removedSymbols.has(symbol)) {
                return undefined;
            }
        }

        return ts.visitEachChild(node, visitor, undefined);
    };
}

async function createApiDtsFiles(dtsBaseDir: string, dtsFiles: string[], projectDir: string, outDir: string) {
    const commandLine = ts.parseCommandLine([]);
    if (!commandLine.options.project) {
        commandLine.options.project = path.resolve(projectDir, 'tsconfig.json');
    }

    const reportDiagnostic = createDiagnosticReporter();

    const parseConfigFileHost: ts.ParseConfigFileHost = <any>ts.sys;
    parseConfigFileHost.onUnRecoverableConfigFileDiagnostic = diagnostic => {
        reportDiagnostic(diagnostic);
        ts.sys.exit(ts.ExitStatus.InvalidProject_OutputsSkipped);
    };

    const parsedCommandLine = ts.getParsedCommandLineOfConfigFile(
        commandLine.options.project!,
        commandLine.options,
        parseConfigFileHost,
        /*extendedConfigCache*/ undefined,
        commandLine.watchOptions
    )!;

    const removedSymbols = new Set<string>();

    const program = ts.createProgram({
        rootNames: dtsFiles,
        options: parsedCommandLine.options,
        projectReferences: parsedCommandLine.projectReferences,
        host: ts.createCompilerHost(parsedCommandLine.options)
    });

    let sourceFiles = program.getRootFileNames().map(f => program.getSourceFile(f)!);

    const checker = program.getTypeChecker();

    // Pass 1: Filter all exports
    sourceFiles = sourceFiles.map(
        s => ts.visitEachChild(s, filterApiVisitor(checker, s, removedSymbols), undefined) as ts.SourceFile
    );

    // Pass 2: Filter imports of remoevd exports
    sourceFiles = sourceFiles.map(
        s => ts.visitEachChild(s, filterImportsVisitor(checker, s, removedSymbols), undefined) as ts.SourceFile
    );

    // Pass 3: filter any files which do not have any exports
    sourceFiles = sourceFiles.filter(s =>
        s.statements.some(
            e =>
                ts.isExportDeclaration(e) ||
                (ts.canHaveModifiers(e) && e.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword))
        )
    );

    // Pass 4(todo): Collect and Filter any unused imports from externals

    const printer = ts.createPrinter({
        newLine: ts.NewLineKind.LineFeed,
        removeComments: false,
        noEmitHelpers: true
    });

    for (const s of sourceFiles) {
        const result = printer.printFile(s);
        const relativePath = path.relative(dtsBaseDir, s.fileName);
        const outputFile = path.resolve(outDir, relativePath);
        await fs.promises.writeFile(outputFile, result);
    }
}

Danielku15 avatar Nov 19 '25 00:11 Danielku15