Alright, let’s get our hands dirty. You’ve met the raw TypeScript Compiler API. It’s powerful, but let’s be honest, it feels like you’re trying to perform open-heart surgery with a rusty spoon while wearing oven mitts. The API is low-level, verbose, and requires you to constantly check the node.flags bitmask to figure out what you’re even looking at. It’s a masterpiece of engineering, but a nightmare of ergonomics.

This is where ts-morph swoops in like a superhero in a nicely tailored suit. It’s a library that wraps the raw Compiler API, giving you all its power but with a sane, object-oriented, and downright pleasant interface. Instead of dealing with ts.SyntaxKind.SomeObscureEnum and ts.isCallExpression(node), you work with clear classes like CallExpression. It’s the difference between assembling a car from a pile of parts and simply turning the key.

Why You Should Probably Just Use ts-morph

I’m not kidding. For probably 95% of your AST manipulation tasks, ts-morph is the correct choice. The raw API is essential knowledge for understanding how it all works under the hood, but for actual productivity, ts-morph is unbeatable. It handles the tedious setup for you—creating a Program, managing SourceFiles, dealing with type checkers—and lets you focus on the actual logic of your code manipulation. It abstracts the gnarly parts without abstracting away the power.

Here’s the classic “get all the classes in a project” example. Compare this to the raw API equivalent and try not to weep with joy.

import { Project } from "ts-morph";

// See? This is already easier. No Compiler API `createProgram` nonsense.
const project = new Project({
  // Load all tsconfig.json files in the provided paths
  tsConfigFilePath: "tsconfig.json",
  // Alternatively, add source files directly
  // addFilesFromTsConfig: true,
});

// Get all your source files. Done.
const sourceFiles = project.getSourceFiles();

// Now, let's find every class declaration in the entire codebase.
const allClasses = [];

for (const sourceFile of sourceFiles) {
  const classesInFile = sourceFile.getClasses();
  allClasses.push(...classesInFile);
}

console.log(`Found ${allClasses.length} classes.`);
// Now you have an array of ClassDeclaration objects, ready to be queried.

The Magic of Wrapped Nodes

The core genius of ts-morph is its wrapper classes. A raw ts.Node is just a node. A ts-morph Node is an object with a plethora of useful methods.

Want to find all the call expressions that call a function named dangerousFunction? It’s a one-liner.

const dangerousCalls = sourceFile
  .getDescendantsOfKind(SyntaxKind.CallExpression)
  .filter(callExpr => callExpr.getExpression().getText() === "dangerousFunction");

But wait, it gets better. What if you want to find calls to a function imported from a specific module? The raw API makes this a multi-step, type-checking nightmare. ts-morph makes it intuitive.

// Let's find all calls to 'something' from '@my/shaky-package'
const importSpecifier = sourceFile
  .getImportDeclaration("@my/shaky-package")
  ?.getNamedImports()
  .find(spec => spec.getName() === "something");

if (importSpecifier) {
  // Now find what this import is aliased to in this file, if at all
  const importIdentifier = importSpecifier.getNameNode();
  const calls = sourceFile
    .getDescendantsOfKind(SyntaxKind.CallExpression)
    .filter(callExpr => {
      const expr = callExpr.getExpression();
      return expr.getText() === importIdentifier.getText();
    });
}

Manipulation and Writing Changes

This is where ts-morph truly ascends to greatness. Modifying the AST with the raw API is a careful dance of factory functions and careful splicing. With ts-morph, you just… change things.

Let’s say you want to rename every instance of a poorly named variable oldVarName to newVarName in a single file. The raw API would require you to find every identifier, check if its text matches, and then replace it, being painfully careful not to break anything. ts-morph gives you a refactor namespace.

const project = new Project();
const sourceFile = project.addSourceFileAtPath("some-file.ts");

// Find all references to 'oldVarName' and rename them.
// This uses the underlying language service to be semantically correct.
// It won't, for example, rename a string that happens to be "oldVarName".
sourceFile.refactor.rename("oldVarName", "newVarName");

// Save the changes. This will overwrite the original file!
await project.save();

You can also manipulate the structure directly:

const myClass = sourceFile.getClass("MyClass")!;

// Add a new property 'z' of type number.
myClass.addProperty({
  name: "z",
  type: "number",
  initializer: "0", // initializer is a string that will be parsed as code
});

// Prepend a new import statement at the top of the file.
sourceFile.addImportDeclaration({
  moduleSpecifier: "fs",
  namedImports: ["readFileSync"],
});

The Gotchas and When to Drop Down

ts-morph isn’t magic fairy dust. It’s a wrapper, and sometimes the wrapping tears.

  1. Performance: For massive codebase-wide operations, the convenience of ts-morph comes with a overhead compared to the raw API. It’s usually negligible, but for truly Herculean tasks, it’s something to be aware of.
  2. Leaky Abstractions: Very, very rarely, you might find a edge case the ts-morph hasn’t wrapped perfectly. You might get a node where the underlying compilerNode property is undefined or doesn’t behave as expected. This is your escape hatch. Every ts-morph node has a .compilerNode property that gives you direct access to the raw ts.Node underneath. It’s like having a panic button for when you absolutely must use the rusty spoon.
  3. Project Setup: While easier, you still have to set up your Project object correctly. If your tsconfig.json is a labyrinth of extends and paths, you might need to help ts-morph out by providing the correct base path or explicitly adding files.

The rule of thumb is this: Start with ts-morph. Build your entire tool with it. Only if you hit a concrete, measurable performance bottleneck or a bizarre bug do you even consider dropping down to the raw compilerNode for that specific operation. For nearly everything you’ll ever want to do, ts-morph isn’t just easier—it’s better. It lets you think about your code’s logic, not the compiler’s bureaucracy.