25.1 Why Use the Compiler API: Linters, Codemods, Generators
Look, you don’t reach for the TypeScript Compiler API because you had a nice, normal day and thought, “You know what sounds relaxing?” You reach for it when you have a problem that can’t be solved by just writing more TypeScript. It’s the power tool for when you need to not just use the language, but understand it, manipulate it, and generate it programmatically. Think of it as the difference between driving a car and being a mechanic with a full diagnostic computer. Most of us just need to drive. But when you need to tune the engine or, heaven forbid, build a new car from scratch, you need the mechanic’s tools.
The API itself is the engine beneath the tsc command. It’s the set of libraries that take your source code, break it down into a structured form (the Abstract Syntax Tree, or AST), and then lets you ask profound questions about it or even rewrite it. We use it for three main superpowers: building linters, writing codemods, and creating code generators.
To Lint with Absolute Authority
Sure, you can write an ESLint rule with some regex and a prayer. But for anything non-trivial, you’re playing with fire. Regex can’t understand type information or the full syntactic structure of your code. The Compiler API can. You want to write a rule that bans using any unless it’s specifically cast from unknown? Or enforces that all React props are read-only? This is where you go.
The key here is the type checker. After parsing the code into a tree of nodes, you get access to the full power of the TypeScript type system. You can ask, “What is the type of this variable?” and get a real answer, not a guess.
import * as ts from 'typescript';
function createProgramAndCheck(filePaths: string[]) {
// Create a Program, the core representation of your codebase
const program = ts.createProgram(filePaths, {});
const checker = program.getTypeChecker(); // Your magic crystal ball for types
// Get the source file for the first file
const sourceFile = program.getSourceFile(filePaths[0]);
// Walk the tree
ts.forEachChild(sourceFile, visitNode);
function visitNode(node: ts.Node) {
if (ts.isVariableDeclaration(node)) {
const name = node.name.getText();
const type = checker.getTypeAtLocation(node);
const typeName = checker.typeToString(type);
// Now you can analyze the type!
if (typeName === 'any') {
console.log(`WARNING: Variable '${name}' is of type 'any', you heathen.`);
}
}
// Recursively walk the children of this node
ts.forEachChild(node, visitNode);
}
}
// Run it on itself, because why not?
createProgramAndCheck(['./example.ts']);
This is a linter on steroids. You’re not just pattern-matching; you’re reasoning about the code’s actual meaning.
To Rewrite Code with Surgical Precision (Codemods)
This is the big one. A codemod is a script that transforms your codebase. Need to rename a method across 500 files? Update a legacy API? Change a prop across every React component? Doing this by hand is a soul-crushing, error-prone nightmare. Doing it with find-and-replace is a great way to accidentally break everything. A codemod using the Compiler API is precise, safe, and repeatable.
The trick is to use the transformer API. You don’t just analyze the tree; you create a new, modified tree. You find the nodes you want to change and you return new nodes in their place.
import * as ts from 'typescript';
import * as fs from 'fs';
// A transformer factory that renames `oldFunction` to `newFunction`
const transformerFactory: ts.TransformerFactory<ts.SourceFile> = (context) => {
return (sourceFile) => {
function visit(node: ts.Node): ts.Node {
node = ts.visitEachChild(node, visit, context); // Visit all children first
if (ts.isCallExpression(node)) {
const expression = node.expression;
if (ts.isIdentifier(expression) && expression.text === 'oldFunction') {
// Create a new Identifier node for the new function name
const newName = ts.factory.createIdentifier('newFunction');
// Return a new CallExpression, copying everything but the function name
return ts.factory.updateCallExpression(node, newName, node.typeArguments, node.arguments);
}
}
return node;
}
return ts.visitNode(sourceFile, visit);
};
};
const program = ts.createProgram(['./file-to-modify.ts'], {});
const sourceFile = program.getSourceFile('./file-to-modify.ts');
const transformResult = ts.transform(sourceFile, [transformerFactory]);
const transformedSourceFile = transformResult.transformed[0];
// Print the new, beautiful code
const printer = ts.createPrinter();
const newCode = printer.printFile(transformedSourceFile);
fs.writeFileSync('./file-to-modify.ts', newCode);
This approach understands the structure. It won’t accidentally rename a variable called oldFunction; it only changes actual function calls. This is the difference between a scalpel and a sledgehammer.
To Generate Code That Doesn’t Suck
The final power move is generation. You’ve probably used a tool that generated a bunch of unreadable, unmaintainable code. With the Compiler API, you can be the one who builds that tool, but you can do it right. You can generate perfect, formatted, syntactically correct TypeScript by building the AST node-by-node and then printing it.
Why is this better than string templating (console.log('function ${name}() {}'))? Because you will never, ever have a syntax error or a missing semicolon in the wrong place. The printer handles all the formatting for you. It’s glorious.
import * as ts from 'typescript';
// Let's generate a simple function `function greet(name: string) { return "Hello, " + name; }`
const functionName = ts.factory.createIdentifier("greet");
const parameter = ts.factory.createParameterDeclaration(
undefined, // modifiers
undefined, // dotDotDotToken
"name", // name
undefined, // questionToken
ts.factory.createTypeReferenceNode("string", undefined) // type
);
const returnStatement = ts.factory.createReturnStatement(
ts.factory.createBinaryExpression(
ts.factory.createStringLiteral("Hello, "),
ts.SyntaxKind.PlusToken,
ts.factory.createIdentifier("name")
)
);
const block = ts.factory.createBlock([returnStatement], true);
const functionDeclaration = ts.factory.createFunctionDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], // make it `export`
undefined, // asteriskToken (for generators)
functionName,
undefined, // typeParameters
[parameter], // parameters
undefined, // return type (let TS infer it)
block
);
// Create a source file to hold our new function
const sourceFile = ts.factory.createSourceFile(
[functionDeclaration],
ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
ts.NodeFlags.None
);
// Print it out
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const result = printer.printFile(sourceFile);
console.log(result);
// Outputs perfectly formatted:
// export function greet(name: string) {
// return "Hello, " + name;
// }
The API is verbose, I won’t lie. It feels like building a ship in a bottle. But the result is flawless, predictable code. This is how you build the next great ORM, API client, or state management library.
The common pitfall across all these uses? Performance. Creating a Program and type checker for your entire project is expensive. For a CI linter, it’s fine. For an editor extension, you need to be incredibly smart about it, often reusing a single program instance provided by the editor’s language service. The other pitfall is the learning curve. The AST is huge and the APIs are… comprehensive. You will spend a lot of time in the TypeScript AST viewer website, and that’s okay. It means you’re doing it right.