Skip to content

Instantly share code, notes, and snippets.

@asvny
Created May 5, 2026 10:48
Show Gist options
  • Select an option

  • Save asvny/0cd8e599b37b6120ff9f8d67515fce92 to your computer and use it in GitHub Desktop.

Select an option

Save asvny/0cd8e599b37b6120ff9f8d67515fce92 to your computer and use it in GitHub Desktop.
ts transformer mangle notes

SPEC — ts-transformer-properties-rename

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.


1. Purpose

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 declared private.
  • ${internalPrefix}${originalName} — for everything else that is not reachable from an entry point.

A property whose visibility resolves to External is not renamed.


2. Inputs

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>.


3. The visibility model

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 for private class 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.

3.1 Symbol normalization

Before lookup, each symbol is normalized:

  1. If the symbol has the Alias flag, follow getAliasedSymbol once. (Re-export and import { X } chains collapse to the originating symbol.)
  2. 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 | InternalInterface correctly preserve Options.fooBar (External) even when InternalInterface.fooBar is Internal — see binding-element and in-operator fixtures.

3.2 Reachability from entry points (ExportsSymbolTree)

A symbol is "reachable" if walking outwards from any entry-point export hits it. The walk is:

  1. For each entrySourceFile, take its module symbol. Resolve exports via getExportsOfModule. Special-case CommonJS export = (InternalSymbolName.ExportEquals) and export default (always include).
  2. 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 why export const x = (() => new InternalClass())(); does not make InternalClass's members public unless the variable's static type exposes them. See export-variable-with-iife.
    • Inside Block nodes and JSDoc comments — skip; bodies are implementation.
    • On ClassDeclaration / InterfaceDeclaration / etc., walk every child Identifier. 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 (the private keyword) — they cannot leak through public API even if their containing class is exported.
  3. Track a per-walk visitedSymbols set so cycles terminate.

A symbol is "accessible from exports" iff it appears in any exports tree built from any entry point.

3.3 Forced-External escape hatches

Beyond reachability, a symbol resolves to External in any of these cases:

  1. Declared in an external file. A declaration source file is "external" if isDeclarationFile === true, or program.isSourceFileDefaultLibrary(...) returns true, or its path matches /[\\/]node_modules[\\/]/. Any property whose symbol is declared in such a file is External — that's how MouseEvent.button, Window.onorientationchange, Array.length, import * as moduleName, and .d.ts-imported classes are kept intact (see tuple, accessing-import, import-from-dts, in-operator).
  2. Has the declare modifier. Any declaration (in user code) with declare makes its symbol External — see useDefineForClassFields-and-declare.
  3. Property name is prototype. Accessing .prototype itself is always External (member access through the prototype is then handled normally — see accessing-via-prototype).
  4. @public JSDoc tag on the declaration or any ancestor up to the SourceFile (only when publicJSDocTag is non-empty). Searching is done by substring match on the raw leading-trivia text — no JSDoc parser. The match string is literally @${publicJSDocTag}. See mark-as-public-with-jsdoc and mark-mapped-types-as-public-with-jsdoc.
  5. Decorator on the declaration or any ancestor up to the SourceFile, when ignoreDecorated is true. See decorators.
  6. Symbol cannot be resolved at all for the node (e.g. degenerate cases). The transformer treats null symbol as External — preserve, don't rename.
  7. Module / namespace member access on the LHS. When the LHS of x.foo / x['foo'] is an Identifier whose symbol is a NamespaceImport or ModuleDeclaration, the property is forced External. The transformer cannot rename across module/namespace boundaries (the binding may be visible elsewhere). See accessing-import, accessing-namespace.
  8. Tuple element access. Any property of a tuple type (ObjectFlags.Tuple) is External — covers [a, b].length, indexed access, destructuring (const [first, second] = tup). See tuple, tuple-complex.
  9. any / unknown typed expressions. Property access through any or unknown resolves 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 through as chains — see §6.4 and the any/unknown fixtures.
  10. JSX prop name whose props-type resolves the prop to External. See §6.6.

3.4 Private classification

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.

3.5 Inherited / containing-type classification (the "lift" rule)

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.


4. The type-property-external check (isTypePropertyExternal)

This is the predicate used wherever the transformer needs "is property P of type T accessible to the outside world?". It runs recursively:

  1. any / unknown → External (true). Cannot prove anything about an unknown type.
  2. IndexedAccessType (T[K]) → recurse on the apparent type. See indexed-access-in-this.
  3. Tuple (any tuple) → External. As above.
  4. Type reference (ObjectFlags.Reference) → recurse on the target (the un-instantiated generic). E.g. Readonly<Foo> → check Foo. See mapped-types.
  5. 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 to Foo; the latter has no underlying named type, so the property goes External only if the mapped-type alias was tagged External (e.g. @public). See mark-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).
  6. Union / intersection → recurse on each constituent; any constituent External ⇒ External. So Options | InternalInterface keeps fooBar External because of Options. See binding-element, in-operator.
  7. Heritage clauses (class extends / implements, interface extends) → 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 and export-interface.
  8. 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 so open/high/low/close are kept (since Ohlc is exported). See indexed-generic-as-returned-type.
  • Tuple-or-number unions: tuple branch forces External, so destructuring const [first, ...] = tupleOrNumber is preserved. See tuple-complex.

5. Syntax sites that participate in renaming

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 = 3 and this[356] are preserved — see numbered-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 match ComputedPropertyName.
  • Element access with a non-string argument (variable, computed expression) is not handled — only StringLiteral arguments are rewritten. So obj[someVar] is untouched.
  • The rewrite is purely a name substitution; surrounding nodes (PropertyAccessExpression, ElementAccessExpression, BindingElement, JsxAttribute, ...) are reconstructed via context.factory only when needed (sites 6, 8, 11).

6. Site-by-site contextual-type rules

The visibility classification at a use site uses the contextual type, not the declared type, in the following ways:

6.1 Object literal property assignment ({ foo: 1 }, { foo })

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.

6.2 Property access / element access (obj.foo, obj['foo'])

Visibility is determined from getTypeAtLocation(obj) and §4 against 'foo'. The module/namespace short-circuit (§3.3.7) runs first.

6.3 Destructuring (const { foo } = obj, const { foo: x } = obj)

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.

6.4 'name' in expr

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).

6.5 Constructor parameter property reference

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.

6.6 JSX attribute (<C foo={...} />)

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.


7. Naming rules

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).


8. Behaviors that are deliberately preserved (must not change)

These are not bugs; downstream code depends on them.

  1. prototype access is always preserved (§3.3.3).
  2. Tuple element accesses are never renamed (§3.3.8).
  3. any / unknown-typed property accesses preserve the property name (§3.3.9, §4.1).
  4. Module-namespace and import * as access points are preserved (§3.3.7).
  5. Properties of types declared in .d.ts / node_modules / default lib are preserved (§3.3.1).
  6. Exported enum members are preserved; non-exported enum members are renamed (§5.4 + §3.2). See enums.
  7. 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).
  8. protected is not Private; it follows reachability like everything else (reference-ctor-params).
  9. declare-modified members are External regardless of their visibility modifier (useDefineForClassFields-and-declare).
  10. Cross-instance same-class private access is consistent: rhs.privateField from inside the same class still rewrites to the same name (privates-accessing-to-other-instance).

9. Edge cases worth re-testing on a ts-morph rewrite

These are the cases most likely to break under a naive port. Each is exercised by a fixture (named in parens).

  1. Transient symbol splitting. Options | InternalInterface where TS gives a fused symbol — must split to recover both constituents (binding-element, in-operator).
  2. 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).
  3. 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).
  4. Mapped types. Three sub-cases:
    • Readonly<Foo> should forward to Foo via ObjectFlags.Reference.target (mapped-types, External-with-@public in mark-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).
  5. 'name' in obj with a string-literal LHS — must rewrite the LITERAL itself, not the RHS (in-operator).
  6. JSX prop renaming on a custom component with non-exported props interface — must consult tag's value declaration's type (tsx).
  7. Constructor parameter properties — the parameter identifier and every body reference must end up with the same renamed name; without it, this.x = x produces a ReferenceError (reference-ctor-params, privates-declaring-members-in-ctor-params).
  8. Destructuring this. const { privateField } = this becomes var { _private_privateField: privateField } = this (or the IIFE-emitted equivalent) — the binding element is rewritten so the local variable name is unchanged (privates-destructuring-this).
  9. Numeric and string-literal class members. private 356: number; this[356]; — passes through; numbers are not renamed (numbered-props).
  10. JSDoc @public on a Record<string, string> constant — propagates to all element accesses on that constant (mark-as-public-with-jsdoc).
  11. Decorators with ignoreDecorated: true — the decorated declaration and anything below it is External; the test runs with ignoreDecorated: true (decorators).
  12. declare keyword on class fields — fields with declare keep their original name; methods on the same class without declare follow normal rules (useDefineForClassFields-and-declare).
  13. __foo (leading underscores)escapedName un-escaping must not double-escape (ts-escaped-identifiers).
  14. Element access via dynamic key like obj[someVar] is intentionally untouched. Don't introduce a runtime helper.
  15. CommonJS export =getExportsForSourceFile must short-circuit on InternalSymbolName.ExportEquals and return only that one symbol; default export must be additionally included if missing.

10. Things the transformer does NOT do

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 * as or namespace are forced External.
  • It does not parse JSDoc; the @public check 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.

11. Test-fixture index (ground truth)

Every directory under tests/test-cases/ holds an input.ts/input.tsx and an output.js. The test harness:

  1. Builds a ts.Program rooted at the input file with target: ES5 (overridden by any local tsconfig.json in the case dir).
  2. Asserts no pre-emit diagnostics.
  3. Runs the transformer with entrySourceFiles: [input file], the default prefixes, publicJSDocTag: 'public', and ignoreDecorated: true.
  4. program.emit() and asserts the emitted JS matches output.js byte-for-byte (after CRLF normalization and trim).

Categories of fixtures (each name = directory under tests/test-cases/):

  • Reachability basicssimple, export-interface, export-variable-with-iife, accessing-non-exported-class, default-value.
  • Class internalsclass-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 / preservedaccessing-import, accessing-namespace, import-from-dts, tuple, tuple-complex, any, unknown, numbered-props, useDefineForClassFields-and-declare.
  • Generics & mapped/indexedgeneric-with-type, generic-with-string-type, indexed-access-in-this, indexed-generic-as-returned-type, mapped-types, exported-mapped-types.
  • Object literals & destructuringshorthand-property-assignment, binding-element, element-access-with-string-literal.
  • Enumsenums.
  • in operatorin-operator.
  • Identifier escapingts-escaped-identifiers.
  • JSXtsx.
  • JSDoc / decoratorsmark-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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment