TypeScript 99 🔷 Writing Custom TypeScript Transformers
The TypeScript compiler can be extended through a mechanism called a transformer. A transformer is a function that receives the Abstract Syntax Tree during compilation and returns a modified version of it. It runs as part of the tsc build, between the parsing stage and the emitting stage, and it can add nodes, remove nodes, or replace nodes before the JavaScript output is generated . This is how the compiler applies its own downleveling rules, and it is also how third-party tools inject new behavior into the build.
Custom transformers are used for a range of tasks: compile-time code generation, macro expansion, performance instrumentation, dependency injection, and framework-specific transformations. Angular’s compiler, for example, uses transformers to convert decorators into runtime metadata. The mechanism is not part of the public TypeScript API in the same way that the type checker is — it has historically required a tool like ts-patch or ttypescript to register transformers with the build .
Key point: A transformer is a function that takes a TransformationContext and returns a function that takes a SourceFile and returns a SourceFile. Inside the inner function, you walk the AST with a visitor and return new nodes in place of old ones. The transformer runs during the before phase, which means it sees the original TypeScript code before the compiler has downleveled it to JavaScript .
Why custom transformers exist
The TypeScript compiler ships with a fixed set of transformations: converting ES2015+ syntax to older targets, stripping type annotations, converting module syntax, and so on. These are the transformations every project needs. Custom transformers are for the transformations only your project needs.
The code generation problem. A library might want to generate code at build time based on the application’s types. An API client generator, for example, could read a schema and produce typed request functions. A transformer can add those functions to the AST without the developer writing them by hand.
The macro problem. A syntax extension like @Memoize or @Log can be implemented as a transformer. The developer writes the decorator or annotation, and the transformer rewrites the annotated code to add the memoization or logging logic. The output is plain JavaScript with no runtime decorator overhead.
The instrumentation problem. A build might need to add timing calls to every function, or wrap every async operation with error tracking. A transformer can insert the calls at the AST level, uniformly across the codebase, without modifying the source files.
The framework problem. Angular, Vue, and other frameworks use transformers to convert their template syntax and decorators into the code that the framework’s runtime expects. The developer writes framework-specific code, and the transformer converts it to standard JavaScript.
The trade-off. Transformers are powerful but fragile. The TypeScript compiler’s AST is not a stable public API. Node structures change between TypeScript versions. A transformer written for TypeScript 4.x may break on 5.x. The factory API and the visitEachChild function are the stable parts, but the shape of specific nodes — like FunctionDeclaration or ClassDeclaration — can change. A custom transformer is a dependency on the compiler’s internals, and it must be maintained as those internals evolve .
a. The Transformer Entry Point
A transformer is registered as a plugin. The registration format depends on the tool that runs it. With ts-patch, the transformer is specified in tsconfig.json under compilerOptions.plugins .
{
"compilerOptions": {
"plugins": [
{ "transform": "./transformers/my-transformer.ts" }
]
}
}
The entry point for a source transformer is a function with this signature:
(program: ts.Program, config: PluginConfig, extras: TransformerExtras) => ts.TransformerFactory
The function receives the Program, the plugin’s configuration from tsconfig.json, and an extras object that includes the TypeScript instance. It returns a TransformerFactory, which is the function that the compiler calls for each source file .
The TransformerFactory has this shape:
(context: ts.TransformationContext) => (sourceFile: ts.SourceFile) => ts.SourceFile
The outer function receives the transformation context. The inner function receives a source file and returns a new source file. The context provides the factory — the API for creating new nodes — and the visitEachChild function for traversing the tree .
A minimal transformer that does nothing looks like this:
import type * as ts from 'typescript';
export default function (program: ts.Program) {
return (context: ts.TransformationContext) => {
return (sourceFile: ts.SourceFile) => {
return sourceFile;
};
};
}
The transformer is registered with ts-patch by running tspc instead of tsc, or by using the ts-patch/compiler path in tools like ts-node and ts-jest .
b. Writing the Visitor
The visitor is where the transformation happens. A visitor is a function that receives a node and returns either the same node, a new node, or undefined to remove the node. The visitEachChild function traverses the node’s children and applies the visitor to each of them .
The pattern is:
import type * as ts from 'typescript';
export default function (program: ts.Program) {
return (context: ts.TransformationContext) => {
const { factory } = context;
return (sourceFile: ts.SourceFile) => {
function visit(node: ts.Node): ts.Node {
if (ts.isStringLiteral(node) && node.text === 'before') {
return factory.createStringLiteral('after');
}
return ts.visitEachChild(node, visit, context);
}
return ts.visitNode(sourceFile, visit) as ts.SourceFile;
};
};
}
The visit function checks whether the current node is a string literal with the text 'before'. If it is, it returns a new string literal with the text 'after'. If it is not, it calls visitEachChild to traverse the node’s children and apply the visitor recursively. The ts.visitNode call at the end starts the traversal at the source file .
The factory object is the API for creating new nodes. It has methods like createStringLiteral, createIdentifier, createCallExpression, and createImportDeclaration. Each method returns a new AST node. The factory is the replacement for the deprecated ts.create* functions that were used before TypeScript 4.0 .
A common task is adding a new statement to a file. The updateSourceFile method takes the original source file and a new array of statements:
import type * as ts from 'typescript';
function addImportToEndOfFile(sourceFile: ts.SourceFile, factory: ts.NodeFactory): ts.SourceFile {
const functionName = factory.createIdentifier('foo');
const importDeclaration = factory.createImportDeclaration(
undefined,
factory.createImportClause(
false,
undefined,
factory.createNamedImports([
factory.createImportSpecifier(
false,
undefined,
factory.createIdentifier('foo')
)
])
),
factory.createStringLiteral('my-library')
);
const methodCall = factory.createCallExpression(
functionName,
undefined,
undefined
);
return factory.updateSourceFile(sourceFile, [
...sourceFile.statements,
importDeclaration,
factory.createExpressionStatement(methodCall)
] as ts.Statement[]);
}
This creates an import of foo from my-library and a call to foo() at the end of the file. However, there is a known limitation: the call expression’s identifier may not have a flowNode, which means the TypeScript compiler will not link it to the import. The emitted code will use foo() as a global reference instead of my_library_1.foo(). The workaround is to inline the require call or to use a property access on the imported module .
c. Traversal and Node Updates
The visitor pattern is the standard way to traverse the AST. The ts.visitEachChild function calls the visitor on each child of the current node. The visitor can return the same node, a new node, or undefined to remove the node from the tree .
The ts.visitNode function is a convenience wrapper that calls the visitor on a single node. It is used at the top level to start the traversal:
const transformed = ts.visitNode(sourceFile, visit);
For nodes that need to be updated rather than replaced, the factory provides update* methods. For example, factory.updateFunctionDeclaration takes the original function declaration and the new pieces:
if (ts.isFunctionDeclaration(node)) {
const updatedNode = factory.updateFunctionDeclaration(
node,
node.modifiers,
node.asteriskToken,
node.name,
node.typeParameters,
node.parameters,
node.type,
node.body
);
return ts.visitEachChild(updatedNode, visit, context);
}
The update methods preserve the node’s pos and end properties, which track the node’s position in the source file. This matters for error reporting and source maps. When you create a node with factory.create*, the pos and end are set to -1, which means the node is synthetic and has no position in the original source. If a later transformer or the emitter calls node.getText(), it may fail because the position information is missing .
The ts.setSourceMapRange function can be used to copy the source map range from an original node to a synthetic one. This preserves the mapping to the original source for debugging .
Complete Example Session
This session builds a transformer that replaces a placeholder function call with a generated implementation, and registers it with ts-patch.
// ============================================
// PART 1: THE TARGET CODE
// ============================================
// src/index.ts
const result = __GENERATE__();
console.log(result);
// The transformer will replace __GENERATE__() with
// a string literal "generated at build time".
// ============================================
// PART 2: THE TRANSFORMER
// ============================================
// transformers/generate-transformer.ts
import type * as ts from 'typescript';
import type { TransformerExtras, PluginConfig } from 'ts-patch';
export default function (
program: ts.Program,
config: PluginConfig,
{ ts: tsInstance }: TransformerExtras
) {
return (context: ts.TransformationContext) => {
const { factory } = context;
return (sourceFile: ts.SourceFile) => {
function visit(node: ts.Node): ts.Node {
// Find a call expression whose callee is __GENERATE__
if (
tsInstance.isCallExpression(node) &&
tsInstance.isIdentifier(node.expression) &&
node.expression.text === '__GENERATE__'
) {
// Replace it with a string literal
return factory.createStringLiteral(
`generated at build time: ${new Date().toISOString()}`
);
}
return tsInstance.visitEachChild(node, visit, context);
}
return tsInstance.visitNode(sourceFile, visit) as ts.SourceFile;
};
};
}
The transformer uses tsInstance from the extras object instead of importing TypeScript directly. This ensures that the transformer uses the same TypeScript instance as the compiler, avoiding version mismatches .
// ============================================
// PART 3: THE TSCONFIG REGISTRATION
// ============================================
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "./dist",
"strict": true,
"plugins": [
{ "transform": "./transformers/generate-transformer.ts" }
]
},
"include": ["src/**/*"]
}
The plugins array in compilerOptions is where ts-patch looks for transformers. The transform key is the path to the transformer module .
// ============================================
// PART 4: INSTALLING AND RUNNING
// ============================================
// Install ts-patch
// npm install --save-dev ts-patch
// Patch the TypeScript installation (persistent mode)
// npx ts-patch install
// Run the compiler with ts-patch
// npx tspc
// Or with the live compiler (no persistent patch needed)
// npx ts-patch/compiler
// Output: dist/index.js
// const result = "generated at build time: 2026-09-29T...";
// console.log(result);
// ============================================
// PART 5: THE INCREMENTAL WATCHER
// ============================================
// ts-patch works with TypeScript's watch mode
// npx tspc --watch
// The transformer runs on every rebuild.
// The watcher uses the builder program API to
// re-check only affected files.
// ============================================
// PART 6: THE AFTER TRANSFORMER
// ============================================
// A transformer can run after TypeScript's own
// transformations by setting "after": true
// in the plugin configuration.
// tsconfig.json
{
"compilerOptions": {
"plugins": [
{ "transform": "./transformers/my-transformer.ts", "after": true }
]
}
}
// The after transformer sees JavaScript code, not TypeScript.
// The AST nodes are the same, but the type annotations are gone.
// ============================================
// PART 7: THE DECLARATION TRANSFORMER
// ============================================
// A transformer can run on declaration files (.d.ts)
// by setting "afterDeclarations": true.
// tsconfig.json
{
"compilerOptions": {
"plugins": [
{ "transform": "./transformers/dts-transformer.ts", "afterDeclarations": true }
]
}
}
// The declaration transformer can add, remove, or modify
// type definitions in the emitted .d.ts files.
// ============================================
// PART 8: THE PROGRAM TRANSFORMER
// ============================================
// A transformer can hook into ts.createProgram()
// by setting "transformProgram": true.
// The entry point receives the Program and returns
// a new Program. This is used to add files to the
// compilation or to modify compiler options.
// transformers/program-transformer.ts
import type * as ts from 'typescript';
import type { TransformerExtras } from 'ts-patch';
export default function (
program: ts.Program,
config: unknown,
{ ts: tsInstance }: TransformerExtras
) {
// Return a function that creates a new Program
return (rootNames: readonly string[], options, host, oldProgram) => {
const newRootNames = [...rootNames, 'src/generated.ts'];
return tsInstance.createProgram(newRootNames, options, host, oldProgram);
};
}
// ============================================
// PART 9: THE DIAGNOSTIC MODIFICATION
// ============================================
// ts-patch can add, remove, or modify diagnostics.
// This is used to suppress errors in generated code
// or to add custom warnings.
// The exact API depends on the ts-patch version.
// See the ts-patch documentation for the current API.
// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. Developer writes src/index.ts
// 2. ts-patch intercepts the compiler
// 3. The parser builds the AST
// 4. The transformer runs, replacing __GENERATE__()
// 5. The checker validates the transformed AST
// 6. The emitter writes dist/index.js
// 7. The transformed code is what runs in production
The ten parts cover the target code, the transformer, the tsconfig.json registration, installing and running, the incremental watcher, the after transformer, the declaration transformer, the program transformer, diagnostic modification, and the complete flow.
Quick Reference
The Transformer Signature
| Part | Signature |
|---|---|
| Entry point | (program, config, extras) => TransformerFactory |
| Factory | (context) => (sourceFile) => sourceFile |
| Visitor | (node) => Node | undefined |
| Traversal | ts.visitEachChild(node, visit, context) |
| Top-level | ts.visitNode(sourceFile, visit) |
The Plugin Options
| Option | Type | Purpose |
|---|---|---|
transform | string | Module name or path to transformer |
after | boolean | Run after TypeScript’s transforms |
afterDeclarations | boolean | Run on .d.ts files |
transformProgram | boolean | Hook into ts.createProgram() |
isEsm | boolean | ES Module transformer (experimental) |
type | string | Entry point type (default: 'program') |
import | string | Named export (default: 'default') |
The Factory Methods
| Method | Purpose |
|---|---|
createStringLiteral | Create a string literal |
createIdentifier | Create an identifier |
createCallExpression | Create a function call |
createImportDeclaration | Create an import |
createExpressionStatement | Wrap an expression as a statement |
updateSourceFile | Replace a source file’s statements |
updateFunctionDeclaration | Update a function declaration |
The Common Patterns
| Pattern | Code |
|---|---|
| Replace a node | return factory.create...() |
| Remove a node | return undefined |
| Update a node | return factory.update...(node, ...) |
| Traverse children | return ts.visitEachChild(node, visit, context) |
Best Practices
✅ Do This:
// Use the ts instance from extras
export default function (program, config, { ts: tsInstance }) { ... } // ✅
// Use factory methods for new nodes
factory.createStringLiteral('after') // ✅
// Use update methods for modified nodes
factory.updateFunctionDeclaration(node, ...) // ✅
// Return undefined to remove a node
if (shouldRemove(node)) return undefined; // ✅
// Register with ts-patch in tsconfig.json
{ "plugins": [{ "transform": "./transformers/my.ts" }] } // ✅
❌ Don’t Do This:
// Don't import typescript directly in the transformer
import * as ts from 'typescript'; // may mismatch the compiler version // ❌
// Don't mutate the original node
node.text = 'after'; // nodes are immutable // ❌
// Don't rely on node.getText() for synthetic nodes
const text = newNode.getText(); // pos/end are -1 // ❌
// Don't assume the AST shape is stable across versions
// Check the TypeScript release notes for AST changes. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Transformer not running | Not registered with ts-patch | Use tspc or ts-patch/compiler |
| Wrong TypeScript version | Imported directly | Use extras.ts |
getText() crashes | Synthetic node has pos = -1 | Use node.text for literals |
| Import not linked | Missing flowNode on synthetic identifier | Inline the require or use property access |
| Emitted code wrong | Transformer runs after downlevel | Use after: true if needed |
| Build slow | Full type check after transform | Use the builder program API |
Real-World Examples
1. Basic Transformer
export default function (program, config, { ts }) {
return (context) => (sourceFile) => sourceFile;
}
2. Replace a String
if (ts.isStringLiteral(node) && node.text === 'before') {
return factory.createStringLiteral('after');
}
3. Add an Import
factory.createImportDeclaration(undefined, factory.createImportClause(...), factory.createStringLiteral('lib'));
4. Add a Statement
factory.updateSourceFile(sourceFile, [...sourceFile.statements, newStatement]);
5. Update a Function
factory.updateFunctionDeclaration(node, node.modifiers, node.asteriskToken, node.name, ...);
6. Remove a Node
if (ts.isDebuggerStatement(node)) return undefined;
7. Registration in tsconfig
{ "plugins": [{ "transform": "./transformers/my.ts" }] }
8. After Transformer
{ "transform": "./transformers/my.ts", "after": true }
9. Declaration Transformer
{ "transform": "./transformers/dts.ts", "afterDeclarations": true }
10. Run with ts-patch
npx tspc
Visual
The Transformer Pipeline
┌──────────────────────────────────────────────┐
│ TRANSFORMER PIPELINE │
│ │
│ Source .ts │
│ │ │
│ ▼ │
│ Parser ──> AST │
│ │ │
│ ▼ │
│ BEFORE transformers │
│ └─ Your custom transformer │
│ └─ TypeScript's transformers │
│ │ │
│ ▼ │
│ Checker ──> Type validation │
│ │ │
│ ▼ │
│ AFTER transformers │
│ │ │
│ ▼ │
│ Emitter ──> JavaScript │
│ │
└──────────────────────────────────────────────┘
The Visitor Pattern
┌──────────────────────────────────────────────┐
│ VISITOR PATTERN │
│ │
│ function visit(node: ts.Node): ts.Node { │
│ if (matches(node)) { │
│ return newNodes; │
│ } │
│ return ts.visitEachChild(node, visit, ctx);│
│ } │
│ │
│ ts.visitNode(sourceFile, visit); │
│ │
│ Each node is visited exactly once. │
│ The visitor returns the replacement or the │
│ original node. │
│ │
└──────────────────────────────────────────────┘
The Factory API
┌──────────────────────────────────────────────┐
│ FACTORY API │
│ │
│ factory.createStringLiteral('text') │
│ └─ Returns a new StringLiteral node │
│ │
│ factory.createIdentifier('name') │
│ └─ Returns a new Identifier node │
│ │
│ factory.createCallExpression(fn, args) │
│ └─ Returns a new CallExpression node │
│ │
│ factory.updateSourceFile(sf, statements) │
│ └─ Returns a new SourceFile node │
│ │
│ Nodes are immutable. The factory creates │
│ new nodes; update methods preserve pos/end. │
│ │
└──────────────────────────────────────────────┘
The ts-patch Registration
┌──────────────────────────────────────────────┐
│ TS-PATCH REGISTRATION │
│ │
│ tsconfig.json: │
│ { │
│ "compilerOptions": { │
│ "plugins": [ │
│ { "transform": "./my-transformer.ts" }│
│ ] │
│ } │
│ } │
│ │
│ Run: │
│ npx tspc │
│ │
│ Or: │
│ npx ts-patch/compiler │
│ │
│ The transformer is loaded from the path │
│ and applied to every source file. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Transformer | Function that modifies the AST |
| Entry point | (program, config, extras) => TransformerFactory |
| Factory | (context) => (sourceFile) => sourceFile |
| Visitor | (node) => Node | undefined |
| Traversal | ts.visitEachChild(node, visit, context) |
| Factory API | context.factory.create* |
| Update API | context.factory.update* |
before phase | Runs before TypeScript’s transforms |
after phase | Runs after TypeScript’s transforms |
afterDeclarations | Runs on .d.ts files |
| Registration | tsconfig.json plugins array |
| Runner | ts-patch (tspc or ts-patch/compiler) |
Key takeaways:
- A transformer is a function that receives the AST and returns a modified AST. It runs during the compilation process, between parsing and emitting. It can add, remove, or replace nodes before the JavaScript output is generated .
- The entry point receives the
Program, the plugin config, and anextrasobject. Theextrasobject contains the TypeScript instance, which should be used instead of importing TypeScript directly to avoid version mismatches . - The visitor pattern is how the AST is traversed. The
visitEachChildfunction applies the visitor to each child of a node. The visitor returns the same node, a new node, orundefinedto remove the node . - The factory API creates new nodes.
factory.createStringLiteral,factory.createIdentifier,factory.createCallExpression, and similar methods return new AST nodes. Theupdate*methods create modified versions of existing nodes while preserving their position information . - Transformers run in three phases:
before,after, andafterDeclarations. Thebeforephase sees the original TypeScript. Theafterphase sees JavaScript after TypeScript’s own transforms. TheafterDeclarationsphase sees the emitted.d.tsfiles . ts-patchis the standard way to register transformers. It patches the TypeScript compiler to read thepluginsarray fromtsconfig.jsonand load the transformers from the paths specified. Thetspccommand runs the patched compiler .- Transformers are powerful but fragile. The TypeScript AST is not a stable public API. Node structures can change between TypeScript versions. A transformer must be maintained as the compiler evolves. The
factoryandvisitEachChildAPIs are the stable parts; the shape of specific nodes is not .
Remember: A custom transformer is a function that modifies the AST during compilation. It is registered as a plugin and runs during the before phase, which means it sees the original TypeScript code. The visitor pattern traverses the tree, and the factory API creates new nodes. Transformers are used for code generation, macros, instrumentation, and framework-specific compilation. They are powerful because they can rewrite any part of the program. They are fragile because they depend on the compiler’s internal AST, which is not guaranteed to be stable. Use them when the alternative — writing the generated code by hand or adding a runtime library — is worse. Test them against multiple TypeScript versions. And remember that the AST you are modifying is the same AST the compiler uses for type checking and emitting, so a mistake in your transformer produces broken JavaScript.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!