The TypeScript compiler API lets you programmatically inspect, transform, and generate TypeScript code, essentially treating your codebase as data.
Let’s see it in action. Imagine you want to automatically add a console.log statement before every function call. This is a common debugging or tracing technique.
Here’s a simplified example using the ts package:
import * as ts from 'typescript';
const sourceCode = `
function greet(name: string) {
console.log("Hello, " + name);
}
greet("World");
`;
const sourceFile = ts.createSourceFile(
'example.ts',
sourceCode,
ts.ScriptTarget.ESNext,
true
);
const visitor = (node: ts.Node): ts.VisitResult<ts.Node> => {
// Check if the node is a CallExpression (like greet("World"))
if (ts.isCallExpression(node)) {
// Create a new CallExpression for console.log
const logCall = ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('console'),
ts.factory.createIdentifier('log')
),
undefined, // type arguments
[ts.factory.createStringLiteral(`Calling: ${node.expression.getText(sourceFile)}`)]
);
// Return a new SyntaxList containing the log call and the original call
return ts.factory.createNodeArray([logCall, node]);
}
// Continue traversing the AST
return ts.visitEachChild(node, visitor, null);
};
const transformedSourceFile = ts.visitNode(sourceFile, visitor);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const transformedCode = printer.printFile(transformedSourceFile);
console.log(transformedCode);
When you run this, the output will be:
function greet(name: string) {
console.log("Hello, " + name);
}
console.log("Calling: greet");
greet("World");
This demonstrates how you can intercept specific code constructs (like CallExpressions) and inject new ones.
The core problem the TypeScript compiler API solves is enabling sophisticated code analysis and manipulation beyond what simple string replacements or regular expressions can reliably achieve. It understands the structure and semantics of your TypeScript code.
Internally, the API works with the Abstract Syntax Tree (AST) of your code. When TypeScript compiles your code, it first parses it into an AST. This tree represents the hierarchical structure of your program, with nodes for declarations, expressions, statements, etc. The API allows you to traverse this tree, examine each node, and even create new nodes or modify existing ones.
The key functions you’ll interact with are:
ts.createSourceFile: Parses your code string into an AST (SourceFilenode).ts.visitNodeandts.visitEachChild: These are used for traversing the AST. You provide avisitorfunction that gets called for each node. Your visitor function can decide to return a new node, modify the existing one, or simply callts.visitEachChildto continue traversal.ts.is<NodeType>(e.g.,ts.isCallExpression,ts.isVariableDeclaration): Type guard functions to check the kind of a node.ts.factory: A collection of factory functions for creating new AST nodes (e.g.,ts.factory.createCallExpression,ts.factory.createIdentifier).ts.createPrinter: Used to convert a transformed AST back into a code string.
The AST is a powerful representation, but it’s also a complex one. You’re dealing with specific node types, properties, and relationships. For instance, to add a console.log before a function call, you need to identify the CallExpression node, extract its target (the function being called), and then construct a new CallExpression for console.log and arrange them in a SyntaxList or Block to ensure sequential execution.
The most surprising thing about working with the compiler API is how much information is readily available on AST nodes that isn’t immediately obvious from the TypeScript language itself. For example, a FunctionDeclaration node has properties like typeParameters, parameters, body, and name. You can also access decorators and modifiers (like export, async, static). This rich metadata allows for incredibly precise code transformations. You can check if a function is async, if it has specific JSDoc tags, or if its return type is a promise, and conditionally apply transformations based on these details, all without needing to re-parse or infer types from scratch in your transformation logic.
Once you’ve mastered basic AST traversal and manipulation, you’ll likely want to explore integrating these transforms into your build process, perhaps using tools like ts-loader with custom transformers or by building standalone CLI tools.