ts-prefix-internals
v0.2.4
Published
TypeScript symbol prefixer for aggressive minification — prefix internal symbols with _ so terser/esbuild can mangle them via mangleProps: /^_/
Maintainers
Readme
ts-prefix-internals
A TypeScript CLI tool that prefixes internal symbols with _ so that terser or esbuild can aggressively mangle them via mangleProps: /^_/.
The Problem
JavaScript minifiers like terser and esbuild can mangle local variable names, but they cannot safely mangle property and method names. They have no way to know which names are internal implementation details versus part of a public API or DOM/library interface. Mangling the wrong property name breaks your code at runtime.
This means class properties, method names, and other member identifiers ship unminified in your production bundles, often accounting for a significant portion of the remaining code size after standard minification.
The Solution
TypeScript's compiler API does know which symbols are internal. It has full type information, knows about exports, class visibility modifiers, and the entire dependency graph.
ts-prefix-internals uses this information to:
- Discover your public API surface from barrel export entry points
- Classify every symbol in your project as public or internal
- Prefix internal symbols with
_using the TypeScript Language Service for safe, cross-file renaming - Validate the output compiles without errors
After prefixing, you configure your minifier to mangle everything matching /^_/:
// terser
{
mangle: {
properties: { regex: /^_/ }
}
}
// esbuild
{
mangleProps: /^_/
}What Gets Prefixed
Internal (will be prefixed):
- Classes, functions, and variables not exported from your entry points
- Private members of exported classes
- All members of non-exported classes
- Internal interfaces, type aliases, and enums
Public API (will NOT be prefixed):
- Symbols exported from entry point files
- Public/protected members of exported classes
- Members of exported interfaces and enums
- Types referenced in public API signatures (followed recursively)
- Anything from
node_modulesor.d.tsfiles - Symbols already starting with
_
Install
npm install -D ts-prefix-internalsUsage
CLI
# Preview what would be renamed
npx ts-prefix-internals -p tsconfig.json -e src/index.ts -o .prefixed --dry-run
# Rename and write output
npx ts-prefix-internals -p tsconfig.json -e src/index.ts -o .prefixed
# Multiple entry points
npx ts-prefix-internals -p tsconfig.json -e src/index.ts -e src/server.ts -o .prefixedOptions
-p, --project <path> Path to tsconfig.json (required)
-e, --entry <file> Public API entry point file(s), repeatable (required)
-o, --outDir <dir> Output directory for rewritten files (required)
--prefix <string> Prefix string (default: "_")
--dry-run Report what would be renamed without writing files
--verbose Print every rename decision with reasoning
--skip-validation Skip post-rename type-check
--force Continue despite dynamic-access errors (exit 0)
-h, --help Show helpProgrammatic API
import { prefixInternals } from 'ts-prefix-internals';
const result = await prefixInternals({
projectPath: 'tsconfig.json',
entryPoints: ['src/index.ts'],
outDir: '.prefixed',
prefix: '_',
dryRun: false,
verbose: false,
skipValidation: false,
});
console.log(`Prefixed ${result.willPrefix.length} symbols`);
console.log(`Kept ${result.willNotPrefix.length} public API symbols`);
if (result.validationErrors) {
console.error('Output has type errors:', result.validationErrors);
}Example
Given a project with a barrel export:
// src/index.ts
export { Processor } from './engine';
export { Coord, CoordKind } from './types';And an internal class not exported from the barrel:
// src/graph.ts
export class LinkMap {
private forward: Map<string, Set<string>>;
connect(a: string, b: string): void { /* ... */ }
}Running the tool produces:
WILL PREFIX (internal):
LinkMap class graph.ts:1 -> _LinkMap
LinkMap.forward property graph.ts:2 -> _forward
LinkMap.connect method graph.ts:4 -> _connect
Processor.links property engine.ts:5 -> _links
Processor.pending property engine.ts:6 -> _pending
WILL NOT PREFIX (public API):
Processor class engine.ts:4 (exported)
Processor.setEntry method engine.ts:15 (public member)
Coord interface types.ts:1 (exported)
Coord.ns property types.ts:2 (interface member)The output directory contains valid TypeScript where all internal symbols are prefixed, ready for aggressive minification.
How It Works
API Surface Discovery (
api-surface.ts) -- Starting from entry point barrel exports, recursively discovers every public symbol. Resolves alias chains (export { Foo } from './foo'), walks type signatures to find referenced types, includes public/protected class members, interface members, and enum members.Symbol Classification (
classifier.ts) -- Walks every source file and classifies each symbol. Uses the public API set to determine what's internal. Handles private class members, constructor parameter properties, and generates warnings for dynamic property access.Renaming (
renamer.ts) -- Uses the TypeScript Language ServicefindRenameLocationsAPI for safe cross-file renaming. Collects all edits first, deduplicates, sorts in reverse order, and applies bottom-to-top to avoid position shifts.Validation -- Compiles the output with
tsc --noEmitto verify the renaming was safe.
Build Pipeline Integration
A typical build pipeline using this tool:
# 1. Prefix internal symbols
npx ts-prefix-internals -p tsconfig.json -e src/index.ts -o .prefixed
# 2. Compile the prefixed source
cd .prefixed && tsc
# 3. Bundle and mangle with terser/esbuild
esbuild .prefixed/dist/index.js --bundle --minify --mangle-props=_Or as package.json scripts:
{
"scripts": {
"prefix": "ts-prefix-internals -p tsconfig.json -e src/index.ts -o .prefixed",
"build": "npm run prefix && cd .prefixed && tsc && esbuild dist/index.js --bundle --minify --mangle-props=_"
}
}Releasing
# patch release (default)
npm run release
# minor/major/prerelease
npm run release -- minorRelease script behavior:
- Verifies you're on
mainand the working tree is clean - Runs tests
- Bumps version with
npm version - Pushes branch + tag
Publishing behavior:
- Tag pushes matching
v*trigger.github/workflows/publish.yml - Workflow builds/tests, verifies tag version matches
package.json, publishes to npm, and creates a GitHub Release - Prerelease versions (for example
1.2.0-beta.1) publish with npm dist-tagnext
One-time setup:
- In npm, configure a Trusted Publisher for this GitHub repo and workflow file
- In GitHub, create environment
npm-releaseand optionally require reviewers / restrict tags - Protect release tags (for example
v*) with a GitHub ruleset so only maintainers can create them
Safety
- Uses the TypeScript Language Service for renaming (not regex/text replacement)
- Validates output compiles after renaming
- Never modifies symbols from
node_modulesor.d.tsfiles - Skips decorated symbols (decorator name reflection would break)
- Detects dynamic property access with three-tier diagnostics:
- Error: bracket access with a string literal type that matches a prefixed property (provably broken)
- Warn: bracket access with a broad
stringoranytype (may break) - Silent: array/tuple indexing (safe, suppressed)
- Skips symbols already starting with
_
Requirements
- Node.js >= 18
- TypeScript >= 5.0 (as a project dependency)
License
MIT
