This document specifies the behavior of the transformer so it can be re-implemented (e.g. on top of ts-morph) without re-deriving the rules from the test fixtures. It is a behavioral spec, not an architectural one — it says what must happen for any given input, not how the current implementation does it.
The reference implementation lives in src/transformer.ts, src/exports-symbol-tree.ts, and src/typescript-helpers.ts. The fixtures under tests/test-cases/ are the ground truth — anything below that disagrees with a fixture is wrong.
Rename every property/member that is not reachable from a configured set of entry points so a downstream property mangler (terser/uglify with mangle.properties.regex) can shorten them. The renaming is purely a prefix substitution at every read/write/declaration site of the property; the rename must be globally consistent so the program continues to behave identically at runtime.
Output names take one of two shapes:
${privatePrefix}${originalName}— for class members declaredprivate.${internalPrefix}${originalName}— for everything else that is not reachable from an entry point.
A property whose visibility resolves to External is not renamed.
The transformer is configured with RenameOptions:
| Option | Default | Meaning |
|---|---|---|
entrySourceFiles |
[] |
Absolute/relative paths to entry-point source files. Anything reachable from these is treated as the public API and is preserved. An empty list means everything is internal. |
privatePrefix |
'_private_' |
Prefix used for private class members. |
internalPrefix |
'_internal_' |
Prefix used for everything else that gets renamed. |
publicJSDocTag |
'public' |
A JSDoc tag (without the @) that, when present on a declaration or any of its parents up to the source file, forces visibility to External. Set to '' to disable. |
ignoreDecorated |
false |
When true, any node whose declaration (or any parent of it) has at least one decorator is forced to External. |
The transformer itself returns a ts.TransformerFactory<ts.SourceFile>.
Every property reference resolves to exactly one of three visibility classes:
- External — must NOT be renamed. Either reachable from an entry point, or one of the escape hatches in §3.3 applies.
- Private — gets
privatePrefix. Used only forprivateclass members (§3.4). - Internal — gets
internalPrefix. Default outcome for symbols that are neither External nor Private.
Visibility is computed per symbol, with caching keyed on the un-aliased ts.Symbol. A given symbol always resolves to the same class within one transform run.
Before lookup, each symbol is normalized:
- If the symbol has the
Aliasflag, followgetAliasedSymbolonce. (Re-export andimport { X }chains collapse to the originating symbol.) - Transient symbols (declaration-merged / contextually-typed symbols where TS fuses multiple symbols into one) are split back into their constituent symbols by walking each declaration and resolving the symbol at its name. Visibility is computed for each constituent; if any of them is External, the whole reference is External. This is what makes
Options | InternalInterfacecorrectly preserveOptions.fooBar(External) even whenInternalInterface.fooBaris Internal — seebinding-elementandin-operatorfixtures.
A symbol is "reachable" if walking outwards from any entry-point export hits it. The walk is:
- For each
entrySourceFile, take its module symbol. Resolve exports viagetExportsOfModule. Special-case CommonJSexport =(InternalSymbolName.ExportEquals) andexport default(always include). - For each top-level export, recursively walk types it depends on, not implementation:
- For a
VariableDeclaration, follow the type of the variable (getTypeAtLocation) — never its initializer. This is whyexport const x = (() => new InternalClass())();does not makeInternalClass's members public unless the variable's static type exposes them. Seeexport-variable-with-iife. - Inside
Blocknodes and JSDoc comments — skip; bodies are implementation. - On
ClassDeclaration/InterfaceDeclaration/ etc., walk every childIdentifier. For each, resolve to its symbol, normalize (alias + transient split), add to the reachable set, and recurse on each of that symbol's declarations. - Skip class members that are syntactically
private(theprivatekeyword) — they cannot leak through public API even if their containing class is exported.
- For a
- Track a per-walk
visitedSymbolsset so cycles terminate.
A symbol is "accessible from exports" iff it appears in any exports tree built from any entry point.
Beyond reachability, a symbol resolves to External in any of these cases:
- Declared in an external file. A declaration source file is "external" if
isDeclarationFile === true, orprogram.isSourceFileDefaultLibrary(...)returns true, or its path matches/[\\/]node_modules[\\/]/. Any property whose symbol is declared in such a file is External — that's howMouseEvent.button,Window.onorientationchange,Array.length,import * as moduleName, and.d.ts-imported classes are kept intact (seetuple,accessing-import,import-from-dts,in-operator). - Has the
declaremodifier. Any declaration (in user code) withdeclaremakes its symbol External — seeuseDefineForClassFields-and-declare. - Property name is
prototype. Accessing.prototypeitself is always External (member access through the prototype is then handled normally — seeaccessing-via-prototype). @publicJSDoc tag on the declaration or any ancestor up to the SourceFile (only whenpublicJSDocTagis non-empty). Searching is done by substring match on the raw leading-trivia text — no JSDoc parser. The match string is literally@${publicJSDocTag}. Seemark-as-public-with-jsdocandmark-mapped-types-as-public-with-jsdoc.- Decorator on the declaration or any ancestor up to the SourceFile, when
ignoreDecoratedis true. Seedecorators. - Symbol cannot be resolved at all for the node (e.g. degenerate cases). The transformer treats
nullsymbol as External — preserve, don't rename. - Module / namespace member access on the LHS. When the LHS of
x.foo/x['foo']is anIdentifierwhose symbol is aNamespaceImportorModuleDeclaration, the property is forced External. The transformer cannot rename across module/namespace boundaries (the binding may be visible elsewhere). Seeaccessing-import,accessing-namespace. - Tuple element access. Any property of a tuple type (
ObjectFlags.Tuple) is External — covers[a, b].length, indexed access, destructuring (const [first, second] = tup). Seetuple,tuple-complex. any/unknowntyped expressions. Property access throughanyorunknownresolves the host type to External, so the property is preserved. Note however that the literal site itself still gets evaluated against any other contextual types that flow throughaschains — see §6.4 and theany/unknownfixtures.- JSX prop name whose props-type resolves the prop to External. See §6.6.
A symbol is Private iff it has at least one declaration that is a class member (method/property/getter/setter or a constructor parameter property) carrying the private keyword. protected is NOT private — protected members go through normal Internal/External classification (the reference-ctor-params fixture shows protected prot becoming _internal_prot because it was not reachable from an entry point and not private). Static private members behave the same as instance private members (privates-static-fields).
The Private classification short-circuits before reachability; even if a private member is somehow walked by §3.2, it gets the Private prefix, not preserved.
When computing visibility for a symbol that has a declaration whose parent.name is itself a named declaration (interface / class / type alias / variable / etc.), if the parent declaration's symbol is External, the child is also External. This makes /** @public */ const colors: Record<string, string> = { ... } propagate External to colors[...] lookups even though no entry point references the keys (mark-as-public-with-jsdoc).
When computing visibility for a property reference on a class instance, if the containing class type itself has the property as External (e.g. via an implemented interface that's reachable from exports), the reference is External even if the bare property symbol is Internal. This is the "interface implementation" rule from README Example 2.
This is the predicate used wherever the transformer needs "is property P of type T accessible to the outside world?". It runs recursively:
any/unknown→ External (true). Cannot prove anything about an unknown type.IndexedAccessType(T[K]) → recurse on the apparent type. Seeindexed-access-in-this.Tuple(any tuple) → External. As above.- Type reference (
ObjectFlags.Reference) → recurse on thetarget(the un-instantiated generic). E.g.Readonly<Foo>→ checkFoo. Seemapped-types. - Mapped type with no declaration on the property symbol — if the parent type's symbol itself is External, treat the property as External. This is the
Readonly<Foo>vs.{ [K in 'foo' | 'bar']: any }split: the former forwards toFoo; the latter has no underlying named type, so the property goes External only if the mapped-type alias was tagged External (e.g.@public). Seemark-mapped-types-as-public-with-jsdoc(External via@public) vs.exported-mapped-types(External because the alias is itself exported) vs.mapped-types(Internal — alias is internal). - Union / intersection → recurse on each constituent; any constituent External ⇒ External. So
Options | InternalInterfacekeepsfooBarExternal because ofOptions. Seebinding-element,in-operator. - Heritage clauses (class
extends/implements, interfaceextends) → recurse into each base type for the same property name. Implementing an exported interface keeps the implementing-class member's name. See README Example 2 andexport-interface. - Property symbol — if
getPropertyOfType(type, name)is undefined → return false (not external; let the symbol-level rules decide). Otherwise, check the property symbol and all its transient-split constituents; any External ⇒ External.
Notes:
- Indexed-generic-as-returned-type:
BarValueByType[T]— recursion into apparent type / heritage / union resolves correctly soopen/high/low/closeare kept (sinceOhlcis exported). Seeindexed-generic-as-returned-type. - Tuple-or-number unions: tuple branch forces External, so destructuring
const [first, ...] = tupleOrNumberis preserved. Seetuple-complex.
The visitor inspects every node and rewrites only the following sites. Only these sites are rewritten — anything else passes through untouched.
| # | Pattern | Example | Output node kind |
|---|---|---|---|
| 1 | Property access — RHS identifier | obj.foo |
Identifier |
| 2 | Element access — string-literal arg | obj['foo'] |
StringLiteral |
| 3 | Class member name | private foo() {}, public foo: number (also getters/setters, static) |
Identifier |
| 4 | Enum member name | enum E { Foo } |
Identifier |
| 5 | Property assignment key | { foo: 1 } |
Identifier |
| 6 | Shorthand property assignment | { foo } |
rewritten to PropertyAssignment { <new>: foo } if the property is renamed; otherwise unchanged |
| 7 | Binding element with explicit propertyName | const { foo: x } = obj |
rewrites the foo |
| 8 | Shorthand binding element | const { foo } = obj |
rewrites to { <new>: foo } so the local foo keeps its name |
| 9 | JSX attribute name | <C foo={...} /> |
Identifier (only when the JSX element's props type resolves the prop to non-External) |
| 10 | 'foo' in obj |
string literal becomes the new name if the RHS type's property foo is non-External |
StringLiteral |
| 11 | Constructor parameter property | constructor(private foo: T) |
rewrites both the parameter name AND every reference to foo inside the constructor body that resolves to the same class-member symbol |
Notes:
- Numeric property names are passed through.
private 356: number = 3andthis[356]are preserved — seenumbered-props. The transformer only rewrites identifiers and string literals; numeric literals fall through untouched. - Computed property names (e.g.
class C { [Symbol.iterator]() {} }) are not handled — the visitor does not matchComputedPropertyName. - Element access with a non-string argument (variable, computed expression) is not handled — only
StringLiteralarguments are rewritten. Soobj[someVar]is untouched. - The rewrite is purely a name substitution; surrounding nodes (PropertyAccessExpression, ElementAccessExpression, BindingElement, JsxAttribute, ...) are reconstructed via
context.factoryonly when needed (sites 6, 8, 11).
The visibility classification at a use site uses the contextual type, not the declared type, in the following ways:
The property's visibility is decided against the contextual type of the enclosing object literal. The lookup walks outward through ParenthesizedExpression and AsExpression wrappers, but always uses the contextual type of the innermost expression that hasn't yet been re-typed by an as. Concretely: when a chain of casts is present, take the contextual type of the expression just before the outermost as you traversed. This is what allows the unknown-cast-soup in the unknown and any fixtures to land on the right type. If the resulting contextual type is undefined (no cast and no expected type), only the symbol-level rules apply and the property is typically Internal.
Visibility is determined from getTypeAtLocation(obj) and §4 against 'foo'. The module/namespace short-circuit (§3.3.7) runs first.
The host type is getTypeAtLocation(obj) (the BindingPattern.parent). For shorthand ({ foo }), the bound symbol is resolved via the host type's property lookup (type.getProperty(name)), mimicking TS Server's go-to-definition; if the host type is a Union, the shorthand symbol is treated as null ⇒ External (because we can't safely pick one). For named binding ({ foo: x }) we just look up the property symbol normally.
The string-literal 'name' is rewritten only if name is non-External on getTypeAtLocation(expr.right). This relies on TypeScript narrowing the LHS to a string-literal type (the transformer requires it). For LHS unions like MouseEvent | TouchEvent where neither has the property, the property's symbol won't resolve and renaming is skipped (see 'button' in moveEvent in in-operator).
When a parameter has a visibility modifier (public / protected / private), the parameter creates a class member. References to the parameter name inside the constructor body that resolve to that class-member symbol get rewritten to the same name as the field. This is essential because TS emits both function Class(privateField) { this.privateField = privateField; ... } and the body must use the same identifier in both places. See reference-ctor-params and privates-declaring-members-in-ctor-params.
The host type is computed from the JSX tag's value declaration: getTypeOfSymbolAtLocation(tagSymbol, tagSymbol.valueDeclaration). The prop is rewritten only if non-External on that type. See tsx.
For an original property name name:
- If visibility is Private →
${privatePrefix}${name}, default_private_name. - If visibility is Internal →
${internalPrefix}${name}, default_internal_name. - If visibility is External → unchanged.
The "original name" is taken from ts.unescapeLeadingUnderscores(symbol.escapedName), so TypeScript's leading-underscore escaping is undone before prefixing. A field literally named __foo becomes _internal___foo (one prefix, then the original two underscores). See ts-escaped-identifiers.
Numeric "names" (356) and computed names are out of scope (§5).
These are not bugs; downstream code depends on them.
prototypeaccess is always preserved (§3.3.3).- Tuple element accesses are never renamed (§3.3.8).
any/unknown-typed property accesses preserve the property name (§3.3.9, §4.1).- Module-namespace and
import * asaccess points are preserved (§3.3.7). - Properties of types declared in
.d.ts/node_modules/ default lib are preserved (§3.3.1). - Exported enum members are preserved; non-exported enum members are renamed (§5.4 + §3.2). See
enums. - Implementing an exported interface keeps the implementor's matching members public — class members not declared in any reachable interface are Internal (README Example 2,
export-interface). protectedis not Private; it follows reachability like everything else (reference-ctor-params).declare-modified members are External regardless of their visibility modifier (useDefineForClassFields-and-declare).- Cross-instance same-class private access is consistent:
rhs.privateFieldfrom inside the same class still rewrites to the same name (privates-accessing-to-other-instance).
These are the cases most likely to break under a naive port. Each is exercised by a fixture (named in parens).
- Transient symbol splitting.
Options | InternalInterfacewhere TS gives a fused symbol — must split to recover both constituents (binding-element,in-operator). - Cast chains.
({ field: 222 } as unknown as any as Public as Internal)— visibility must be picked off the right node in the chain (unknown,any). - IIFE-exported variables.
export const x = (() => new InternalClass())();— must follow the variable's type, not the body. Members of the inferred class are reachable; members of unrelated classes inside the IIFE are not (export-variable-with-iife). - Mapped types. Three sub-cases:
Readonly<Foo>should forward toFooviaObjectFlags.Reference.target(mapped-types, External-with-@publicinmark-mapped-types-as-public-with-jsdoc).type M = { [P in K]?: ... }with no underlying named type — the mapped-type alias's own symbol carries the visibility.- Exported mapped-type alias — alias is reachable, so its parent-type symbol is External and §4.5 forwards (
exported-mapped-types).
'name' in objwith a string-literal LHS — must rewrite the LITERAL itself, not the RHS (in-operator).- JSX prop renaming on a custom component with non-exported props interface — must consult tag's value declaration's type (
tsx). - Constructor parameter properties — the parameter identifier and every body reference must end up with the same renamed name; without it,
this.x = xproduces a ReferenceError (reference-ctor-params,privates-declaring-members-in-ctor-params). - Destructuring
this.const { privateField } = thisbecomesvar { _private_privateField: privateField } = this(or the IIFE-emitted equivalent) — the binding element is rewritten so the local variable name is unchanged (privates-destructuring-this). - Numeric and string-literal class members.
private 356: number; this[356];— passes through; numbers are not renamed (numbered-props). - JSDoc
@publicon aRecord<string, string>constant — propagates to all element accesses on that constant (mark-as-public-with-jsdoc). - Decorators with
ignoreDecorated: true— the decorated declaration and anything below it is External; the test runs withignoreDecorated: true(decorators). declarekeyword on class fields — fields withdeclarekeep their original name; methods on the same class withoutdeclarefollow normal rules (useDefineForClassFields-and-declare).__foo(leading underscores) —escapedNameun-escaping must not double-escape (ts-escaped-identifiers).- Element access via dynamic key like
obj[someVar]is intentionally untouched. Don't introduce a runtime helper. - CommonJS
export =—getExportsForSourceFilemust short-circuit onInternalSymbolName.ExportEqualsand return only that one symbol; default export must be additionally included if missing.
These are out of scope and a port should NOT add them:
- It does not run terser/uglify; it only inserts predictable prefixes that downstream manglers consume via
mangle.properties.regex. - It does not touch computed property names, numeric property names, or non-string-literal element accesses.
- It does not chase across modules/namespaces — bindings exposed via
import * asornamespaceare forced External. - It does not parse JSDoc; the
@publiccheck is a substring test on raw leading trivia. - It does not synthesize new declarations; every emitted name corresponds 1:1 with an existing identifier/string-literal/binding-element in the source.
- It does not currently attempt to be safe for composite projects where entry points live in another sub-project (README §"How safe renames are").
- It does not rename based on usage analysis (duck typing) — only on declared types via the entry-point reachability graph.
Every directory under tests/test-cases/ holds an input.ts/input.tsx and an output.js. The test harness:
- Builds a
ts.Programrooted at the input file withtarget: ES5(overridden by any localtsconfig.jsonin the case dir). - Asserts no pre-emit diagnostics.
- Runs the transformer with
entrySourceFiles: [input file], the default prefixes,publicJSDocTag: 'public', andignoreDecorated: true. program.emit()and asserts the emitted JS matchesoutput.jsbyte-for-byte (after CRLF normalization and trim).
Categories of fixtures (each name = directory under tests/test-cases/):
- Reachability basics —
simple,export-interface,export-variable-with-iife,accessing-non-exported-class,default-value. - Class internals —
class-properties,private-constructor,privates-static-fields,privates-method-as-props,privates-accessing-this-in-accessing-this,privates-accessing-to-other-instance,privates-declaring-members-in-ctor-params,privates-destructuring-this,accessing-via-prototype,reference-ctor-params. - External / preserved —
accessing-import,accessing-namespace,import-from-dts,tuple,tuple-complex,any,unknown,numbered-props,useDefineForClassFields-and-declare. - Generics & mapped/indexed —
generic-with-type,generic-with-string-type,indexed-access-in-this,indexed-generic-as-returned-type,mapped-types,exported-mapped-types. - Object literals & destructuring —
shorthand-property-assignment,binding-element,element-access-with-string-literal. - Enums —
enums. inoperator —in-operator.- Identifier escaping —
ts-escaped-identifiers. - JSX —
tsx. - JSDoc / decorators —
mark-as-public-with-jsdoc,mark-mapped-types-as-public-with-jsdoc,decorators.
If a port matches every fixture's output.js exactly, it conforms to this spec. Mismatches in any direction are bugs in the port, not in the spec.