The Compiler API and Custom Tooling
Reading your codebase as an AST: writing a codemod, a custom lint rule or a generator, and the maintenance cost of tooling that knows about your syntax.
Reading your codebase as an AST: writing a codemod, a custom lint rule or a generator, and the maintenance cost of tooling that knows about your syntax.
Four hundred files import a function that has been renamed. Your editor's rename works for the ones it can see and misses the dynamic imports, the re-exports and the string references in tests. Find-and-replace catches the string in a comment and breaks it.
The compiler that checks your code can also be used to read and rewrite it, with full knowledge of what every name refers to. By the end of this lesson you will write a codemod, a custom lint rule and a type-driven generator — and know when a script is the wrong tool.
Parsing gives you the syntax tree, with no knowledge of types:
import ts from "typescript";
const source = ts.createSourceFile(
"photo.ts",
await readFile("src/photo.ts", "utf8"),
ts.ScriptTarget.Latest,
true,
);
ts.forEachChild(source, function visit(node) {
if (ts.isFunctionDeclaration(node) && node.name) {
console.log(node.name.text, source.getLineAndCharacterOfPosition(
node.getStart(),
).line + 1);
}
ts.forEachChild(node, visit);
});Syntax only.
Fast, needs no configuration, works on a single file.
It can tell you there is a call to something named save. It
cannot tell you which save.
Syntax plus the type checker.
Loads the whole project, resolves every import, and answers what a symbol is and where it came from.
Slower, and needs a real tsconfig.json.
The second one:
const config = ts.readConfigFile("tsconfig.json", ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
process.cwd(),
);
const program = ts.createProgram(parsed.fileNames, parsed.options);
const checker = program.getTypeChecker();for (const file of program.getSourceFiles()) {
if (file.isDeclarationFile) continue;
ts.forEachChild(file, function visit(node) {
if (ts.isCallExpression(node)) {
const signature = checker.getResolvedSignature(node);
const returnType = signature
? checker.typeToString(checker.getReturnTypeOfSignature(signature))
: "unknown";
console.log(node.getText(), "->", returnType);
}
ts.forEachChild(node, visit);
});
}Slower, and it knows that load on line 40 is the one from
./photos rather than the one from a dependency. That
distinction is what makes a codemod correct rather than
approximate.
The raw API is verbose. ts-morph wraps it in something
writable:
import { Project } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const file of project.getSourceFiles("src/**/*.ts")) {
for (const call of file.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expression = call.getExpression();
if (expression.getText() !== "loadPhoto") continue;
const symbol = expression.getSymbol();
const declaration = symbol?.getDeclarations()?.[0];
if (declaration?.getSourceFile().getFilePath().includes("/photos/")) {
expression.replaceWithText("fetchPhoto");
}
}
}
await project.save();The symbol lookup is the whole point. It rewrites calls to the
loadPhoto from the photos module and leaves alone a local
variable, an unrelated import, and the word in a comment.
Three habits make a codemod safe:
Run it on a clean tree, so git diff is the review.
Type-check afterwards. A codemod that leaves the project broken has told you exactly where its assumptions failed.
Log what it skipped. Silent partial coverage is the failure mode — you believe four hundred files were updated and eleven were not.
Rules that use the checker can see things no pattern match could:
export const noUnvalidatedJson = createRule({
name: "no-unvalidated-json",
meta: {
type: "problem",
docs: { description: "Parse API responses through a schema." },
messages: {
unvalidated: "Validate this response with a schema.",
},
schema: [],
},
defaultOptions: [],
create(context) {
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();
return {
TSAsExpression(node): void {
const inner = services.esTreeNodeToTSNodeMap.get(node.expression);
const type = checker.getTypeAtLocation(inner);
if (checker.typeToString(type) === "any") {
context.report({ node, messageId: "unvalidated" });
}
},
};
},
});That flags await response.json() as Photo — an assertion on an
any — which is the boundary problem from the practice course,
now enforced.
A rule earns its place when a mistake is common, specific and mechanically detectable. Three or four project-specific rules covering real incidents are worth far more than a large borrowed config, because each one corresponds to something that actually happened.
Bad — a type that parses a schema at compile time.
type Columns<S extends string> = S extends `${infer C}, ${infer Rest}`
? C | Columns<Rest>
: S;
type Row<T extends string> = { [K in Columns<T>]: unknown };
function query<S extends string>(sql: S): Row<S>[] { ... }Good — a generator that writes a file.
// tools/generate-types.ts
const types = tables.map(
(table) => `export type ${pascal(table.name)} = {
${table.columns.map((c) => ` ${c.name}: ${tsType(c)};`).join("\n")}
};`,
);
await writeFile("src/db/types.generated.ts", types.join("\n\n"));import type { Photo } from "./types.generated.js";The type-level version is limited by recursion depth, produces unreadable errors, cannot express a column type, and re-runs on every keystroke. The generated file exists: grep finds it, go-to-definition works, the checker checks it once, and a schema change shows up as a diff in code review.
That is the metaprogramming lesson's ranking from the Python course, and it applies identically here — generating to a committed file keeps every tool working, which import-time or type-level computation gives up.
Make it reproducible: generate in CI and fail if the output differs from what is committed. That turns a stale generated file from a mystery into a failing check.
Three cases where something simpler wins.
Your editor's rename
It handles renames correctly and already uses this API. Write a codemod for structural change — reordering parameters, changing a call shape — not for a rename.
A lint rule with an autofix
Beats a one-off script for anything ongoing, because it runs on every commit rather than once.
A regex, for genuinely textual changes
Updating a copyright header does not need an AST.
TWO LEVELS
createSourceFile syntax only; fast, no config
cannot tell what a name refers to
createProgram + checker loads the project; knows every symbol
slower, and correct
checker.getTypeAtLocation(node)
checker.getResolvedSignature(call)
checker.typeToString(type)
symbol.getDeclarations() where does this actually come from
CODEMODS
ts-morph wraps the raw API in something writable
match on the SYMBOL, not the text - that is the whole point
run on a clean tree, so git diff is the review
type-check afterwards
LOG what you skipped - silent partial coverage is the failure
LINT RULES
getParserServices(context) for type-aware rules
worth writing when a mistake is common, specific and
mechanically detectable
three project rules from real incidents beat a large
borrowed config
GENERATING > COMPUTING
a type that parses a schema: recursion limits, unreadable
errors, re-run on every keystroke
a generated FILE: greppable, navigable, checked once, and a
schema change appears in code review
generate in CI and fail if the output differs from what is
committed
WHEN NOT TO
a rename your editor already does this correctly
an ongoing rule a lint rule with an autofix, not a script
a textual change a regex is fine
the API is UNSTABLE between minor versions - pin itYou can now read your codebase as data, rewrite it with knowledge of what every name refers to, and enforce a project-specific rule the standard set does not cover. The judgement to keep is the last one — generating a file beats computing a type, because a file keeps every tool working.
Next is Type Checking Performance, which addresses something this course has mentioned repeatedly without measuring: why an editor becomes slow, how to find the type responsible, and what to do about it.
Before you move on, write a script that lists every exported
function in your project with its return type as the checker
sees it. It is about thirty lines with ts-morph, and reading
the output usually finds at least one place where the inferred
type is wider than anyone intended.