TypeScript Compiler API
The TypeScript Compiler API provides programmatic access to the TypeScript compiler, allowing you to parse, analyze, transform, and generate TypeScript code. This powerful API enables you to build custom code transformers, linters, code generators, and development tools. In this lesson, we'll explore the Abstract Syntax Tree (AST), create custom transformers, generate code, and perform type checking programmatically.
Understanding the TypeScript AST
The Abstract Syntax Tree (AST) is a tree representation of your source code. Every piece of TypeScript code can be represented as nodes in this tree:
// Install TypeScript as a dependency
npm install typescript
// basic-ast.ts
import * as ts from 'typescript';
// Parse source code into an AST
const sourceCode = \`
const greeting: string = "Hello, World!";
function add(a: number, b: number): number {
return a + b;
}
\`;
const sourceFile = ts.createSourceFile(
'example.ts',
sourceCode,
ts.ScriptTarget.Latest,
true
);
// Traverse the AST
function visit(node: ts.Node, depth: number = 0): void {
const indent = ' '.repeat(depth);
console.log(\`${indent}${ts.SyntaxKind[node.kind]}\`);
ts.forEachChild(node, (child) => visit(child, depth + 1));
}
console.log('AST Structure:');
visit(sourceFile);
// Output:
// SourceFile
// VariableStatement
// VariableDeclarationList
// VariableDeclaration
// Identifier
// StringKeyword
// StringLiteral
// FunctionDeclaration
// Identifier
// Parameter
// Identifier
// NumberKeyword
// Parameter
// Identifier
// NumberKeyword
// NumberKeyword
// Block
// ReturnStatement
// BinaryExpression
// Identifier
// PlusToken
// Identifier
Note: The AST represents your code as a hierarchical tree of nodes. Each node has a kind property that identifies what type of syntax element it represents.
Finding and Filtering Nodes
You can search the AST for specific types of nodes:
import * as ts from 'typescript';
// Helper to find nodes by type
function findNodes<T extends ts.Node>(
node: ts.Node,
kind: ts.SyntaxKind
): T[] {
const results: T[] = [];
function visit(node: ts.Node): void {
if (node.kind === kind) {
results.push(node as T);
}
ts.forEachChild(node, visit);
}
visit(node);
return results;
}
const sourceCode = \`
const x = 10;
let y = 20;
var z = 30;
function foo() {}
const bar = () => {};
\`;
const sourceFile = ts.createSourceFile(
'example.ts',
sourceCode,
ts.ScriptTarget.Latest,
true
);
// Find all variable declarations
const variables = findNodes<ts.VariableDeclaration>(
sourceFile,
ts.SyntaxKind.VariableDeclaration
);
console.log('Variable names:');
variables.forEach(v => {
if (ts.isIdentifier(v.name)) {
console.log(\` ${v.name.text}\`);
}
});
// Find all function declarations
const functions = findNodes<ts.FunctionDeclaration>(
sourceFile,
ts.SyntaxKind.FunctionDeclaration
);
console.log('\nFunction names:');
functions.forEach(f => {
if (f.name) {
console.log(\` ${f.name.text}\`);
}
});
// Advanced filtering: Find all const declarations
function findConstDeclarations(node: ts.Node): ts.VariableDeclaration[] {
const results: ts.VariableDeclaration[] = [];
function visit(node: ts.Node): void {
if (ts.isVariableStatement(node)) {
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
if (isConst) {
node.declarationList.declarations.forEach(decl => {
results.push(decl);
});
}
}
ts.forEachChild(node, visit);
}
visit(node);
return results;
}
const constDeclarations = findConstDeclarations(sourceFile);
console.log('\nConst declarations:');
constDeclarations.forEach(decl => {
if (ts.isIdentifier(decl.name)) {
console.log(\` ${decl.name.text}\`);
}
});
Creating Custom Transformers
Transformers allow you to modify the AST before code generation. This is useful for code optimization, adding features, or enforcing patterns:
import * as ts from 'typescript';
// Transform all console.log calls to console.debug
function createConsoleTransformer(): ts.TransformerFactory<ts.SourceFile> {
return (context) => {
return (sourceFile) => {
const visitor = (node: ts.Node): ts.Node => {
// Check if this is console.log
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'console' &&
node.expression.name.text === 'log'
) {
// Replace 'log' with 'debug'
return ts.factory.updateCallExpression(
node,
ts.factory.createPropertyAccessExpression(
node.expression.expression,
'debug'
),
node.typeArguments,
node.arguments
);
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
}
// Apply the transformer
const sourceCode = \`
console.log("Hello");
console.log("World");
console.error("Error");
\`;
const sourceFile = ts.createSourceFile(
'example.ts',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = ts.transform(sourceFile, [createConsoleTransformer()]);
const printer = ts.createPrinter();
const transformedCode = printer.printFile(result.transformed[0]);
console.log('Transformed code:');
console.log(transformedCode);
// Output:
// console.debug("Hello");
// console.debug("World");
// console.error("Error");
result.dispose();
// More advanced transformer: Add logging to all functions
function createFunctionLoggerTransformer(): ts.TransformerFactory<ts.SourceFile> {
return (context) => {
return (sourceFile) => {
const visitor = (node: ts.Node): ts.Node => {
if (ts.isFunctionDeclaration(node) && node.body) {
const functionName = node.name?.text || 'anonymous';
// Create console.log statement
const logStatement = ts.factory.createExpressionStatement(
ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('console'),
'log'
),
undefined,
[ts.factory.createStringLiteral(\`Entering function: ${functionName}\`)]
)
);
// Add log statement at the beginning of the function
const newBody = ts.factory.updateBlock(node.body, [
logStatement,
...node.body.statements
]);
return ts.factory.updateFunctionDeclaration(
node,
node.modifiers,
node.asteriskToken,
node.name,
node.typeParameters,
node.parameters,
node.type,
newBody
);
}
return ts.visitEachChild(node, visitor, context);
};
return ts.visitNode(sourceFile, visitor);
};
};
}
Tip: Use the ts.factory API to create new AST nodes. This ensures that the nodes are properly structured and compatible with the TypeScript compiler.
Code Generation
You can generate TypeScript code programmatically using the factory API:
import * as ts from 'typescript';
// Generate a simple class
function generateUserClass(): ts.ClassDeclaration {
// Create properties
const idProperty = ts.factory.createPropertyDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.PublicKeyword)],
'id',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
undefined
);
const nameProperty = ts.factory.createPropertyDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.PublicKeyword)],
'name',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
undefined
);
// Create constructor
const constructor = ts.factory.createConstructorDeclaration(
undefined,
[
ts.factory.createParameterDeclaration(
undefined,
undefined,
'id',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword)
),
ts.factory.createParameterDeclaration(
undefined,
undefined,
'name',
undefined,
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword)
)
],
ts.factory.createBlock([
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
'id'
),
ts.SyntaxKind.EqualsToken,
ts.factory.createIdentifier('id')
)
),
ts.factory.createExpressionStatement(
ts.factory.createBinaryExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
'name'
),
ts.SyntaxKind.EqualsToken,
ts.factory.createIdentifier('name')
)
)
])
);
// Create method
const greetMethod = ts.factory.createMethodDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.PublicKeyword)],
undefined,
'greet',
undefined,
undefined,
[],
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createBlock([
ts.factory.createReturnStatement(
ts.factory.createTemplateExpression(
ts.factory.createTemplateHead('Hello, my name is '),
[
ts.factory.createTemplateSpan(
ts.factory.createPropertyAccessExpression(
ts.factory.createThis(),
'name'
),
ts.factory.createTemplateTail('')
)
]
)
)
])
);
// Create the class
return ts.factory.createClassDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
'User',
undefined,
undefined,
[idProperty, nameProperty, constructor, greetMethod]
);
}
// Print the generated code
const userClass = generateUserClass();
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const sourceFile = ts.createSourceFile(
'generated.ts',
'',
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.TS
);
const result = printer.printNode(
ts.EmitHint.Unspecified,
userClass,
sourceFile
);
console.log('Generated class:');
console.log(result);
// Output:
// export class User {
// public id: number;
// public name: string;
// constructor(id: number, name: string) {
// this.id = id;
// this.name = name;
// }
// public greet(): string {
// return \`Hello, my name is ${this.name}\`;
// }
// }
Type Checking Programmatically
The Compiler API allows you to perform type checking on code without emitting files:
import * as ts from 'typescript';
import * as path from 'path';
// Create a program for type checking
function createProgram(fileNames: string[], options: ts.CompilerOptions): ts.Program {
const host = ts.createCompilerHost(options);
return ts.createProgram(fileNames, options, host);
}
// Type check a file
function typeCheck(fileName: string): void {
const options: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
strict: true,
noEmit: true
};
const program = createProgram([fileName], options);
const diagnostics = ts.getPreEmitDiagnostics(program);
if (diagnostics.length === 0) {
console.log('✓ No type errors found');
return;
}
console.log(\`✗ Found ${diagnostics.length} type error(s):\`);
diagnostics.forEach(diagnostic => {
if (diagnostic.file) {
const { line, character } = ts.getLineAndCharacterOfPosition(
diagnostic.file,
diagnostic.start!
);
const message = ts.flattenDiagnosticMessageText(
diagnostic.messageText,
'\n'
);
console.log(
\` ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}\`
);
} else {
console.log(
\` ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}\`
);
}
});
}
// Type check with custom type information
function analyzeTypes(sourceCode: string): void {
const sourceFile = ts.createSourceFile(
'temp.ts',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const options: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS
};
const host = ts.createCompilerHost(options);
const originalGetSourceFile = host.getSourceFile;
host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
if (fileName === 'temp.ts') {
return sourceFile;
}
return originalGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
};
const program = ts.createProgram(['temp.ts'], options, host);
const checker = program.getTypeChecker();
function visit(node: ts.Node): void {
if (ts.isVariableDeclaration(node) && node.initializer) {
const type = checker.getTypeAtLocation(node.initializer);
const typeName = checker.typeToString(type);
if (ts.isIdentifier(node.name)) {
console.log(\`Variable '${node.name.text}' has type: ${typeName}\`);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
// Example usage
const code = \`
const name = "John";
const age = 30;
const isActive = true;
const items = [1, 2, 3];
const user = { name: "Jane", age: 25 };
\`;
console.log('Type Analysis:');
analyzeTypes(code);
// Output:
// Variable 'name' has type: "John"
// Variable 'age' has type: 30
// Variable 'isActive' has type: true
// Variable 'items' has type: number[]
// Variable 'user' has type: { name: string; age: number; }
Note: The TypeChecker is the most powerful part of the Compiler API. It provides access to all type information and can answer questions about types, symbols, and their relationships.
Building a Simple Linter
Combine AST traversal and type checking to build custom linting rules:
import * as ts from 'typescript';
interface LintError {
line: number;
column: number;
message: string;
}
// Rule: Disallow 'any' type
function checkForAnyType(sourceFile: ts.SourceFile): LintError[] {
const errors: LintError[] = [];
function visit(node: ts.Node): void {
// Check for explicit 'any' type annotations
if (node.kind === ts.SyntaxKind.AnyKeyword) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
errors.push({
line: line + 1,
column: character + 1,
message: 'Usage of \'any\' type is not allowed'
});
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return errors;
}
// Rule: Require explicit return types for functions
function checkFunctionReturnTypes(sourceFile: ts.SourceFile): LintError[] {
const errors: LintError[] = [];
function visit(node: ts.Node): void {
if (
(ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) &&
!node.type
) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart()
);
const name = node.name ? node.name.getText() : 'anonymous';
errors.push({
line: line + 1,
column: character + 1,
message: \`Function '${name}' is missing return type annotation'
});
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return errors;
}
// Run all linting rules
function lint(sourceCode: string): void {
const sourceFile = ts.createSourceFile(
'test.ts',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const anyTypeErrors = checkForAnyType(sourceFile);
const returnTypeErrors = checkFunctionReturnTypes(sourceFile);
const allErrors = [...anyTypeErrors, ...returnTypeErrors];
if (allErrors.length === 0) {
console.log('✓ No linting errors found');
return;
}
console.log(\`✗ Found ${allErrors.length} linting error(s):\`);
allErrors.forEach(error => {
console.log(\` Line ${error.line}:${error.column} - ${error.message}\`);
});
}
// Test the linter
const testCode = \`
function greet(name: any) {
return "Hello " + name;
}
function calculate(a: number, b: number) {
return a + b;
}
\`;
lint(testCode);
Warning: Working with the Compiler API can be complex. Always refer to the official TypeScript documentation and study existing tools (like ts-morph) that wrap the Compiler API with easier-to-use interfaces.
Exercise:
- Build a transformer that converts all arrow functions to regular function declarations.
- Create a code generator that takes a JSON schema and generates TypeScript interfaces.
- Implement a custom linter rule that enforces naming conventions (e.g., interfaces must start with 'I').
- Build a tool that analyzes a TypeScript codebase and generates a dependency graph showing which files import which.
- Create a transformer that automatically adds JSDoc comments to all exported functions based on their type signatures.
Summary
The TypeScript Compiler API is a powerful tool for building custom development tools. By understanding the AST, you can traverse and analyze code structure. Custom transformers allow you to modify code programmatically. The factory API enables code generation, and the type checker provides deep insights into type information. With these capabilities, you can build linters, code generators, refactoring tools, and much more, extending TypeScript to fit your specific needs.