TypeScript has revolutionized JavaScript development by adding static typing, but its true power lies in its extensibility. While TypeScript’s built-in features handle most use cases, there are scenarios where you need to customize the compilation process—whether to add custom syntax, automate boilerplate, or enforce project-specific rules. Enter custom transformers: powerful tools that let you modify TypeScript’s Abstract Syntax Tree (AST) during compilation, unlocking advanced workflows.
In this blog, we’ll demystify custom transformers, explore their use cases, and walk through building one from scratch. By the end, you’ll be equipped to extend TypeScript’s functionality to fit your project’s unique needs.
Table of Contents
- Introduction to TypeScript’s Compilation Pipeline
- What Are Custom Transformers?
- When to Use Custom Transformers
- Setting Up Your Environment
- Creating Your First Custom Transformer
- Advanced Transformer Techniques
- Practical Use Cases
- Tools and Libraries for Custom Transformers
- Limitations and Considerations
- Conclusion
- References
1.1 Parsing
TypeScript parses your source code (.ts/.tsx) into an Abstract Syntax Tree (AST)—a tree-like representation of the code’s structure. Nodes in the AST represent elements like variables, functions, and expressions.
1.2 Binding (Semantic Analysis)
The compiler resolves references (e.g., variable/function names) and builds a symbol table to track declarations and their scopes. This ensures names are used consistently.
1.3 Type Checking
TypeScript’s type checker validates the AST against your type annotations and inferred types, flagging errors like type mismatches or undefined variables.
1.4 Emitting (Code Generation)
Finally, the compiler converts the AST into JavaScript (.js), applying transformations like downleveling (e.g., converting ES6+ syntax to ES5) and stripping type annotations.
Custom transformers operate during the emitting phase. They intercept the AST after type checking but before code generation, allowing you to modify the tree to alter the final output.
2. What Are Custom Transformers?
A custom transformer is a function that takes an AST node and returns a modified version of that node (or a new node entirely). Think of it as a “middleware” in the compilation pipeline: it can inspect, add, remove, or rewrite nodes to tailor the emitted JavaScript.
Key Concepts
- Transformer Function: The core logic, defined as
(context: ts.TransformationContext) => (node: ts.Node) => ts.Node. It takes a transformation context (for utilities like creating new nodes) and returns a function that processes individual AST nodes. - Before vs. After Transformers:
- Before transformers: Run before TypeScript’s built-in transformations (e.g., downleveling). Use these to modify the AST before TypeScript applies its own logic.
- After transformers: Run after TypeScript’s transformations. Use these to adjust code after TypeScript has downleveled or optimized it (e.g., post-processing).
3. When to Use Custom Transformers
Custom transformers solve problems TypeScript’s built-in features can’t. Use them when:
- You need custom syntax: Add non-standard syntax (e.g., domain-specific keywords) that TypeScript doesn’t natively support.
- You want to automate boilerplate: Generate repetitive code (e.g., Redux actions, API clients) from a schema or annotations.
- You need to enforce rules: Block forbidden patterns (e.g.,
varinstead oflet/const) or inject logging/metrics code. - You want to optimize code: Remove debug statements in production, inline constants, or tree-shake unused code.
- You’re integrating with tools: Bridge TypeScript with other systems (e.g., generating protobuf definitions from TypeScript interfaces).
4. Setting Up Your Environment
To use custom transformers, you’ll need to interact with TypeScript’s Compiler API. Here’s how to set up your project:
4.1 Install Dependencies
npm install typescript @types/typescript --save-dev
typescript: The TypeScript compiler.@types/typescript: Type definitions for the Compiler API (critical for type-safe transformer development).
4.2 Using the Compiler API Directly
The official TypeScript API requires writing a script to invoke the compiler and apply transformers. Here’s a minimal example:
// compile.ts
import * as ts from "typescript";
// Define your transformer (we’ll implement this later)
const myTransformer: ts.TransformerFactory<ts.SourceFile> = (context) => (file) => {
// AST transformation logic here
return file; // Return the unmodified file for now
};
// Configure the compiler
const compilerOptions: ts.CompilerOptions = {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS,
};
// Create a TypeScript program
const program = ts.createProgram(["src/index.ts"], compilerOptions);
// Get the emit result with transformers
const emitResult = program.emit(undefined, undefined, undefined, undefined, {
before: [myTransformer], // Apply transformer before TypeScript’s built-ins
});
// Check for errors
const diagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
if (diagnostics.length > 0) {
console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, {
getCurrentDirectory: () => process.cwd(),
getCanonicalFileName: (f) => f,
getNewLine: () => ts.sys.newLine,
}));
}
4.3 Simplifying with ttypescript
Manually writing compiler scripts is tedious. Instead, use ttypescript (ttsc)—a wrapper around tsc that lets you declare transformers in tsconfig.json.
Install it:
npm install ttypescript --save-dev
Update tsconfig.json to include your transformer:
{
"compilerOptions": {
"plugins": [
{ "transform": "./path/to/my-transformer.ts" } // Path to your transformer
]
}
}
Now compile with ttsc instead of tsc:
npx ttsc
5. Creating Your First Custom Transformer
Let’s build a simple transformer that converts all string literals to uppercase. For example, console.log("hello") becomes console.log("HELLO").
5.1 Understanding the TypeScript AST
To modify the AST, you need to identify the nodes you want to target. Use the TypeScript AST Viewer to inspect code and find node types. For a string literal like "hello", the AST node is a StringLiteral with a text property set to "hello".
5.2 Writing a Transformer Function
Transformers use a visitor pattern to traverse the AST. We’ll use ts.visitEachChild to recursively visit nodes and modify StringLiteral nodes.
// uppercase-strings-transformer.ts
import * as ts from "typescript";
export default function uppercaseStringsTransformer(
context: ts.TransformationContext
): ts.Transformer<ts.SourceFile> {
// Define a visitor function to process nodes
const visitor: ts.Visitor = (node) => {
// Check if the node is a StringLiteral
if (ts.isStringLiteral(node)) {
// Create a new StringLiteral with uppercase text
return ts.factory.createStringLiteral(node.text.toUpperCase());
}
// Recursively visit child nodes
return ts.visitEachChild(node, visitor, context);
};
// Return a transformer that applies the visitor to the source file
return (file) => ts.visitNode(file, visitor);
}
5.3 Integrating with the Compiler
If using ttypescript, update tsconfig.json to point to your transformer:
{
"compilerOptions": {
"plugins": [{ "transform": "./uppercase-strings-transformer.ts" }],
"target": "ES5",
"module": "CommonJS"
},
"include": ["src/**/*"]
}
Create a test file src/index.ts:
// src/index.ts
const message = "hello world";
console.log(message); // Should become "HELLO WORLD"
Compile with npx ttsc and check the output dist/index.js:
"use strict";
const message = "HELLO WORLD";
console.log(message);
Success! The transformer converted the string literal to uppercase.
6. Advanced Transformer Techniques
Let’s explore more powerful transformations, like modifying existing code and generating new nodes.
6.1 Modifying Existing Nodes
Suppose you want to add a console.log statement at the start of every function. Here’s how to target FunctionDeclaration nodes:
// log-functions-transformer.ts
import * as ts from "typescript";
export default function logFunctionsTransformer(
context: ts.TransformationContext
): ts.Transformer<ts.SourceFile> {
const visitor: ts.Visitor = (node) => {
if (ts.isFunctionDeclaration(node) && node.body) {
// Create a console.log statement: console.log(`Function ${name} called`);
const logStatement = ts.factory.createExpressionStatement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier("console"),
"log"
),
undefined,
[ts.factory.createStringLiteral(`Function ${node.name?.text} called`)]
)
);
// Prepend the log statement to the function body
node.body.statements = ts.factory.createNodeArray([
logStatement,
...node.body.statements,
]);
}
return ts.visitEachChild(node, visitor, context);
};
return (file) => ts.visitNode(file, visitor);
}
Input (src/index.ts):
function greet(name: string) {
return `Hello, ${name}`;
}
Output (dist/index.js):
function greet(name) {
console.log("Function greet called");
return "Hello, " + name;
}
6.2 Generating New Code
Transformers can generate entirely new code. For example, let’s auto-generate a getGreeting function if a GREETING constant exists.
// generate-greeting-transformer.ts
import * as ts from "typescript";
export default function generateGreetingTransformer(
context: ts.TransformationContext
): ts.Transformer<ts.SourceFile> {
const visitor: ts.Visitor = (node) => {
if (ts.isSourceFile(node)) {
// Check if the source file contains a const GREETING = "..."
const hasGreeting = node.statements.some((stmt) =>
ts.isVariableStatement(stmt) &&
stmt.declarationList.declarations.some((decl) =>
ts.isIdentifier(decl.name) && decl.name.text === "GREETING"
)
);
if (hasGreeting) {
// Generate a new function: function getGreeting() { return GREETING; }
const getGreetingFunction = ts.factory.createFunctionDeclaration(
undefined,
undefined,
"getGreeting",
undefined,
[],
undefined,
ts.factory.createBlock([
ts.factory.createReturnStatement(ts.factory.createIdentifier("GREETING")),
])
);
// Add the function to the source file’s statements
return ts.factory.updateSourceFile(
node,
[...node.statements, getGreetingFunction]
);
}
}
return ts.visitEachChild(node, visitor, context);
};
return (file) => ts.visitNode(file, visitor);
}
Input:
const GREETING = "Hello, Transformer!";
Output:
const GREETING = "Hello, Transformer!";
function getGreeting() {
return GREETING;
}
6.3 Leveraging Type Information
Transformers can access the type checker to make type-aware decisions. For example, modify only functions that return a number:
// log-number-functions-transformer.ts
import * as ts from "typescript";
export default function logNumberFunctionsTransformer(
program: ts.Program // Inject the program to access the type checker
): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext) => {
const typeChecker = program.getTypeChecker();
const visitor: ts.Visitor = (node) => {
if (ts.isFunctionDeclaration(node) && node.body) {
// Get the function’s return type
const returnType = typeChecker.getTypeAtLocation(node);
if (returnType.isNumber()) {
// Add a log statement for number-returning functions
const logStmt = ts.factory.createExpressionStatement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier("console"),
"log"
),
undefined,
[ts.factory.createStringLiteral(`Number function ${node.name?.text} called`)]
)
);
node.body.statements = ts.factory.createNodeArray([logStmt, ...node.body.statements]);
}
}
return ts.visitEachChild(node, visitor, context);
};
return (file) => ts.visitNode(file, visitor);
};
}
To use this with ttypescript, pass the program to the transformer via tsconfig.json:
{
"compilerOptions": {
"plugins": [
{
"transform": "./log-number-functions-transformer.ts",
"type": "program" // Injects the program into the transformer
}
]
}
}
7. Practical Use Cases
Custom transformers power many real-world tools. Here are common applications:
7.1 Custom Syntax Extensions
Libraries like tsyringe use transformers to add dependency injection syntax (e.g., @injectable()) before decorators were standardized.
7.2 Code Generation
- State Management: Tools like
redux-ts-transformergenerate Redux boilerplate from interfaces. - API Clients: Generate REST/gRPC clients from OpenAPI/Protobuf schemas embedded in TypeScript.
7.3 Performance Optimizations
- Tree-Shaking: Remove unused imports/exports based on static analysis.
- Dead Code Elimination: Strip debug-only code (e.g.,
if (process.env.NODE_ENV === 'development')) in production.
7.4 Linting and Static Analysis
- Enforce project-specific rules (e.g., banning
anytypes) by modifying or flagging problematic nodes. - Integrate with linters like ESLint by preprocessing code before linting.
8. Tools and Libraries for Custom Transformers
Simplify transformer development with these tools:
- ttypescript: Use transformers via
tsconfig.jsonwithout writing compiler scripts. - ts-morph: A wrapper around the TypeScript API that simplifies AST manipulation with a fluent interface.
- ts-transformer-keys: A popular transformer that generates keys for interfaces (e.g.,
keys<MyInterface>()). - ts-transformer-enumerate: Generates union types from object literals.
9. Limitations and Considerations
While powerful, custom transformers have tradeoffs:
- Complexity: AST manipulation is error-prone. Small mistakes (e.g., missing a child node) can break compilation.
- API Stability: TypeScript’s AST API is not officially stable. Major TS updates may require transformer rewrites.
- Debugging: Debugging transformers is difficult—use
console.log(JSON.stringify(node, null, 2))to inspect nodes or the AST Viewer. - Performance: Transformations add overhead to compilation, especially for large projects.
- Type Safety: Transformers modify code after type checking, so they can generate invalid TypeScript/JavaScript if not careful.
10. Conclusion
Custom transformers unlock TypeScript’s full potential by letting you mold the compilation process to your needs. Whether you’re automating boilerplate, adding custom syntax, or optimizing code, transformers empower you to build tools that feel native to TypeScript.
Start small (e.g., the uppercase string transformer), experiment with the AST Viewer, and gradually tackle more complex use cases. With the right tools and mindset, you’ll be extending TypeScript like a pro.