[api-extractor] Keep separate d.ts files but filter public API
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:
- api-extractor to crawl the modules of my project and build the public API surface.
- Potentially the
exportsproperty of the package.json is consulted to filter what's really public from an NPM perspective (mapping might be tricky though). - api-extractor writes the output
d.tsfiles with the same hierarchy to a given output directory (empty d.ts files are omitted). - 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 |
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.
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);
}
}