Right, so you want to mess with the very fabric of your code as it’s being compiled. Not content with just writing TypeScript, you want to reach into the compiler’s guts and twist the knobs. I respect that. It’s how you build the next generation of linters, code formatters, and those fancy tools that feel like magic (until you have to debug them).

We call this a “Custom Transformer,” and it’s your VIP pass to the Abstract Syntax Tree (AST) party. The AST is the compiler’s internal, object-oriented representation of your code. A transformer’s job is to walk through this tree, find the nodes it cares about, and then optionally replace, update, or delete them to produce a new, transformed tree. It’s like performing surgery on your code while it’s still just a thought in the compiler’s brain.

The Anatomy of a Transformer

Before you start performing AST surgery, you need to know your tools. The entire API lives in the typescript namespace (usually imported as ts). Your transformer is fundamentally a factory function that returns a transformation function. This structure exists purely to appease the compiler’s plugin architecture.

Here’s the most basic, “Hello World” transformer that does absolutely nothing. It’s our starting point.

import * as ts from 'typescript';

/**
 * A transformer that does precisely nothing. Useful for testing your setup.
 */
const nothingTransformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
  return (sourceFile) => {
    // Just return the source file untouched.
    return sourceFile;
  };
};

The key here is TransformerFactory<ts.SourceFile>. It’s a function that takes a TransformationContext (which provides utilities for the transformation) and returns a function that takes a SourceFile (the root node of the AST) and returns a SourceFile (the transformed root).

Actually Transforming Something

Let’s move from a useless transformer to a slightly less useless one. A classic first project is changing all string literals to uppercase. It’s simple, visible, and wonderfully obnoxious.

const uppercaseStringLiteralsTransformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
  const visit: ts.Visitor = (node) => {
    // This is the recursive descent into the AST. For each node, we first visit its children.
    node = ts.visitEachChild(node, visit, context);

    // Is this node a string literal?
    if (ts.isStringLiteral(node)) {
      const newText = node.text.toUpperCase();
      // Create a new StringLiteral node, replacing the old one.
      return ts.factory.createStringLiteral(newText);
    }

    // If it's not a string literal, return the node unchanged.
    return node;
  };

  return (sourceFile) => {
    // Kick off the visit starting from the root source file.
    return ts.visitNode(sourceFile, visit) as ts.SourceFile;
  };
};

Let’s break down the visit function. This is the heart of your transformer. The compiler uses the Visitor pattern, and you’re essentially providing a callback that says, “Hey, for every single node in the tree, run this function.”

  1. ts.visitEachChild(node, visit, context): This is the most important line. It recursively visits all the children of the current node first. This is a “bottom-up” traversal. You handle the leaves of the AST (like our string literal) before their parents. If you forget this line, your transformer will literally do nothing because it will never look inside any node.
  2. ts.isStringLiteral(node): This is a TypeScript type guard. It checks if the node is a string literal and narrows the type, so you can safely access its .text property.
  3. ts.factory.createStringLiteral(): You never create nodes with new. You use the factory object, which is your one-stop shop for building any part of the AST. This ensures the nodes are created correctly and are compatible with the current compiler version.

The Dark Art of Context and State

Here’s where it gets real. The context object you get is your lifeline. It contains methods for hoisting declarations, adding synthetic comments, and most importantly, subscribing to notifications from the compiler. Want to know when the transformation starts and ends for each file? That’s how you do it.

But the real power, and the source of most bugs, is managing state. Your transformer factory is called once per program. The function it returns is called once per source file. The visit function is called for every node. You must be incredibly careful about what state you store where.

const countFunctionTransformer: ts.TransformerFactory<ts.SourceFile> = (context) => {

  // ✅ SAFE: This variable is scoped to the factory, runs once per program.
  let totalFunctionsInProgram = 0;

  return (sourceFile) => {

    // ✅ SAFE: This variable is scoped to the file, runs once per file.
    let functionsInThisFile = 0;

    const visit: ts.Visitor = (node) => {
      if (ts.isFunctionDeclaration(node) && node.name) {
        // ✅ SAFE: We're modifying variables in the file scope.
        functionsInThisFile++;
        totalFunctionsInProgram++;

        console.log(`Found function: ${node.name.text}`);
      }
      return ts.visitEachChild(node, visit, context);
    };

    const transformedSourceFile = ts.visitNode(sourceFile, visit) as ts.SourceFile;
    console.log(`File ${sourceFile.fileName} has ${functionsInThisFile} functions.`);
    return transformedSourceFile;
  };
};

// After compilation, you could log the total. This is a trivial example, but imagine tracking imports or generating unique IDs.

The pitfall here is obvious: if you try to store state inside the visit function itself, it will be reset for every node. Plan your statefulness carefully.

Hooking It Up to the Build

Writing the transformer is only half the battle. The other half is convincing the TypeScript compiler to actually use it. You have two main paths, both fraught with their own, special brand of configuration misery.

The tsc API Way (Programmatic): This is the most flexible way. You use the TypeScript compiler API to create a program and emit it yourself.

import * as ts from 'typescript';
import * as path from 'path';

// Create a program from a tsconfig.json
const program = ts.createProgram(['./src/index.ts'], {
  target: ts.ScriptTarget.ES2020,
  module: ts.ModuleKind.CommonJS
});

// Get the transformers to use
const transformers: ts.CustomTransformers = {
  before: [yourCustomTransformer], // your transformer here
};

// Get the diagnostics and emit the program
const { diagnostics, emitResult } = program.emit(
  undefined, // targetSourceFile
  undefined, // writeFile
  undefined, // cancellationToken
  undefined, // emitOnlyDtsFiles
  transformers // <-- This is the key part
);

// Check for errors
if (emitResult && diagnostics.length > 0) {
  console.error(ts.formatDiagnostics(diagnostics, {
    getCurrentDirectory: () => process.cwd(),
    getCanonicalFileName: (fileName) => fileName,
    getNewLine: () => '\n',
  }));
}

The ttypescript/Plugin Way (Configuration): The vanilla tsc command-line compiler doesn’t support plugins. This is one of those “questionable choices” I mentioned. So the community created ttypescript (a drop-in replacement for tsc) to read transformers from your tsconfig.json.

First, install ttypescript. Then, configure your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "plugins": [
      {
        "transform": "./path-to-your-transformer.js",
        "after": true // or "afterDeclarations"
      }
    ]
  }
}

You then run ttsc instead of tsc. It’s simpler but ties you to this specific toolchain.

The biggest “gotcha” across both methods? Source maps. If you change code, you break the source map. The context object gives you tools for creating new source maps, but it’s a complex topic unto itself. For now, just know that if you need accurate debugging, you have to put in the work to get source maps right.

Your transformer is now alive. Use this power wisely. Or don’t. Turning every string into a meme is also a valid use case, I suppose.