Skip to Content
Course content

85: Writing a Simple TypeScript Transformer

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Wait, how do I actually get "inside" the compiler to change code?

You can't just use a regex to find and replace strings if you want this to be reliable. To write a transformer, you have to work with the Abstract Syntax Tree (AST). Think of the AST as a giant nested object that represents the structure of your code. TypeScript provides a "visitor" pattern to navigate this. You write a function that "visits" every node in the tree, checks if it's something you care about, and then either returns the node as-is or returns a modified version of it.

Here is the basic skeleton of a transformer. I usually start with a function that returns another function—the actual transformation logic:

import * as ts from 'typescript';

const transformer: ts.Transformer = (context) => {
  return (rootNode) => {
    const visit = (node: ts.Node): ts.Node => {
      // This is where the magic happens
      return ts.visitEachChild(node, visit, context);
    };
    return ts.visitNode(rootNode, visit);
  };
};

The ts.visitEachChild call is critical. Without it, you're only looking at the root of your file and ignoring everything inside functions, classes, or loops. It's a recursive descent; you're essentially telling TypeScript, "Keep digging deeper until you hit the leaves of the tree."

How do I target a specific function call, like a debug() helper?

Let's say you have a debug('message') function used everywhere in your app, but you want it completely stripped out of the production build to save on bundle size. You don't want it to just be an empty function; you want the call to vanish entirely.

To do this, you look for ts.SyntaxKind.CallExpression. But since there are thousands of function calls in a project, you have to verify that the function being called is actually named "debug".

const visit = (node: ts.Node): ts.Node => {
  if (ts.isCallExpression(node)) {
    // Check if the expression being called is an identifier named 'debug'
    if (ts.isIdentifier(node.expression) && node.expression.text === 'debug') {
      console.log('Found a debug call! Removing it...');
      return undefined; // Returning undefined effectively deletes the node from the AST
    }
  }
  return ts.visitEachChild(node, visit, context);
};

I've found that using the type guards like ts.isCallExpression is much cleaner than manually checking node.kind === ts.SyntaxKind.CallExpression. It gives you better type narrowing inside the if block, so you don't have to cast types constantly.

Can I replace code with something else, or just delete it?

Deleting is easy (just return undefined), but replacing is where transformers get powerful. Suppose instead of deleting debug(), you want to replace it with console.log() during development. You can't just return a string; you have to construct a new AST node using the ts.factory API.

It feels a bit verbose at first because you're essentially building the code manually, but it's the only way to ensure the resulting JavaScript is syntactically correct.

if (ts.isCallExpression(node) && 
    ts.isIdentifier(node.expression) && 
    node.expression.text === 'debug') {
    
  return ts.factory.createCallExpression(
    ts.factory.createPropertyAccessExpression(
      ts.factory.createIdentifier('console'), 
      ts.factory.createIdentifier('log')
    ),
    undefined, 
    node.arguments // Keep the original arguments passed to debug()
  );
}

Just a word of warning: be careful with scope. If you replace a variable name, you aren't checking if that variable is actually defined in the current scope. You're operating on the structure of the code, not necessarily the semantic meaning. If you start doing heavy lifting with variable renaming, you'll need to use the ts.TypeChecker, which is a whole other beast.

How do I actually run this? tsc doesn't have a --transformer flag.

You've probably noticed this by now. The standard tsc compiler is intentionally closed-off; it doesn't let you plug in custom transformers via a config file. This is one of the more frustrating parts of the TS ecosystem.

In a real-world professional setup, you have two main options:

  • ttypescript: This is a wrapper around tsc that allows you to specify transformers in your tsconfig.json. It's the "classic" way to do it.
  • Custom Build Script: You can write a small Node.js script that uses the TypeScript Compiler API to read files, apply your transformer, and write the output to disk.

If you're using Webpack or Vite, they usually have plugins (like ts-loader) that provide a hook to inject these transformers during the transpilation process. I usually prefer the loader approach because it integrates directly into the hot-reload cycle.




📋 Practical Task

Exercise: The "Telemetry Stripper" Transformer

Your goal is to build a TypeScript transformer that identifies all calls to a function named telemetry.send() and replaces them with a no-op (removes them entirely) to prevent tracking data from being sent in a local development environment.

Requirements:

  • The transformer must specifically target CallExpressions.
  • It must check if the call is a PropertyAccessExpression where the object is telemetry and the property is send.
  • The node should be removed from the AST (return undefined).
  • Ensure that other calls to telemetry (e.g., telemetry.init()) are left untouched.

Starter Code Snippet:

// Target this code:
telemetry.send("user_login"); // Should be removed
telemetry.init();             // Should remain
console.log("App started");   // Should remain
Rating
0 0

There are no comments for now.

to be the first to leave a comment.