Writing Custom TypeScript Compiler Transformers
AI generated
<T>
type
TypeScript · Compiler API · AST · Build Tooling
Writing Custom TypeScript Compiler Transformers
How to rewrite the AST at compile time

A TypeScript transformer plugs directly into the compiler pass, before any JavaScript is emitted, and rewrites the abstract syntax tree programmatically. That enables things neither a linter nor a plain build script can do, such as automatically stripping debug code or injecting metadata based on actual type information.

15 min read TransformerFactory · ts.factory · ts-patch TypeScript 5.x

1. What a TypeScript transformer is and why you need one

A TypeScript transformer is a function that is inserted during the compiler pass between parsing the source code and emitting JavaScript, and it rewrites the abstract syntax tree, or AST, in a targeted way. Unlike a linter, which only reports warnings, or a build script, which only replaces whole files, a TypeScript transformer operates at node level within a single file and can swap, remove or newly create individual expressions, function calls or entire declarations.

Typical use cases for a custom TypeScript transformer include automatically stripping console.log calls in production builds, injecting metadata decorators for dependency injection frameworks, automatically extracting translation strings for i18n tooling, or rewriting import paths based on a custom alias convention the regular module resolver does not know about. All of this can only be solved unreliably with text based regular expressions, while a transformer works on the actual tree structure and therefore correctly handles nested or multi line constructs too.

The crucial difference from a Babel plugin: a TypeScript transformer runs inside the TypeScript compiler itself and therefore optionally has access to the full TypeChecker, meaning it can make type based rather than purely syntactic decisions. That opens up possibilities that would simply be impossible on plain AST level without type information.

2. AST basics: Node, SourceFile and traversal

Every TypeScript file is parsed into a tree of Node objects, with a SourceFile node as the root. Every node carries a kind, for example ts.SyntaxKind.CallExpression for a function call or ts.SyntaxKind.VariableDeclaration for a variable declaration, along with child nodes for its sub expressions. A TypeScript transformer has to traverse this tree to find the relevant nodes at all, before it can change them.

Traversal almost always happens via ts.visitEachChild combined with a custom visit function that is called recursively. This function checks for every node whether it matches the pattern being searched for, for example a call to console.log, and returns either the unchanged node, a new replacement node, or undefined to remove the node entirely. For every irrelevant node the traversal is simply delegated to ts.visitEachChild, which automatically descends into the child nodes.


import ts from "typescript";

// Walking the AST: find every ts.SyntaxKind.CallExpression node
function walk(sourceFile: ts.SourceFile) {
  function visit(node: ts.Node) {
    if (ts.isCallExpression(node)) {
      const expressionText = node.expression.getText(sourceFile);
      console.log(`Found call expression: ${expressionText}(...)`);
    }
    ts.forEachChild(node, visit);
  }
  visit(sourceFile);
}

3. The TransformerFactory type in detail

A TypeScript transformer is not passed in directly as a function, but as ts.TransformerFactory<T>, a function that takes a TransformationContext and itself returns a function that transforms the actual node. This double nesting allows holding state once per compiler run in the outer closure, for example a counter for generated IDs, while the inner function is called separately for each individual file.

The TransformationContext itself provides helper functions, among them context.factory for node creation and methods to pull in compiler helpers such as __awaiter when needed. This exact structure, factory function, context, inner transform function, is why a TypeScript transformer looks more complex at first glance than a plain Babel plugin, but in practice follows the same clear flow: set up once, then apply per file.

4. Practical example: stripping console.log calls in production builds

A realistic first example for a TypeScript transformer is automatically stripping every console.log call, so developers don't have to remember to delete them manually before deployment. The transformer detects every call whose expression is a property access console.log, and replaces the entire statement node with undefined, so ts.visitEachChild automatically drops it when assembling the output.

It matters that only actual expression statements are replaced, not for example a console.log call whose return value is reused, which is unusual but syntactically legal in JavaScript. A careful TypeScript transformer therefore also checks the parent node before removing a call entirely, instead of blindly relying on the call name.


import ts from "typescript";

// Transformer factory: strips console.log(...) statements from the output
function stripConsoleLogTransformer(): ts.TransformerFactory<ts.SourceFile> {
  return (context: ts.TransformationContext) => {
    return (sourceFile: ts.SourceFile) => {
      function visit(node: ts.Node): ts.Node | undefined {
        if (
          ts.isExpressionStatement(node) &&
          ts.isCallExpression(node.expression) &&
          ts.isPropertyAccessExpression(node.expression.expression) &&
          node.expression.expression.expression.getText(sourceFile) === "console" &&
          node.expression.expression.name.getText(sourceFile) === "log"
        ) {
          return undefined; // Drop the whole statement from the output
        }
        return ts.visitEachChild(node, visit, context);
      }
      return ts.visitNode(sourceFile, visit) as ts.SourceFile;
    };
  };
}

// Usage with a real Program
const program = ts.createProgram(["src/checkout.ts"], { target: ts.ScriptTarget.ES2020 });
const result = ts.transform(
  program.getSourceFile("src/checkout.ts")!,
  [stripConsoleLogTransformer()]
);

5. Creating new nodes instead of mutating the AST directly

A common beginner mistake when writing a TypeScript transformer is trying to mutate existing AST nodes directly, for example simply reassigning a property of a node. TypeScript nodes are treated as immutable, and the compiler relies on that internally in several places. Instead of mutating, you always create a brand new node via context.factory that already contains the desired change, for example factory.createCallExpression for a new function call or factory.updateSourceFile for a changed file with a new statement list.

The factory API offers both a createX and an updateX function for practically every node type. updateX is the preferred choice when only part of an existing node needs to change, because it automatically checks whether anything actually changed and otherwise returns the original node unchanged. That saves unnecessary recreation and preserves positional information for source maps, which stay important for debugging in production.


import ts from "typescript";

// Transformer that wraps every top-level function call with a timing helper,
// using factory.create* to build brand new nodes instead of mutating existing ones
function wrapWithTimingTransformer(): ts.TransformerFactory<ts.SourceFile> {
  return (context) => {
    const { factory } = context;
    return (sourceFile) => {
      function visit(node: ts.Node): ts.Node {
        if (
          ts.isCallExpression(node) &&
          ts.isIdentifier(node.expression) &&
          node.expression.text.startsWith("track")
        ) {
          // Build: withTiming(() => originalCall(...))
          const wrapped = factory.createCallExpression(
            factory.createIdentifier("withTiming"),
            undefined,
            [factory.createArrowFunction(undefined, undefined, [], undefined, undefined, node)]
          );
          return wrapped;
        }
        return ts.visitEachChild(node, visit, context);
      }
      return factory.updateSourceFile(
        sourceFile,
        ts.visitLexicalEnvironment(sourceFile.statements, visit as any, context)
      );
    };
  };
}

6. Transformer chains: before, after and afterDeclarations

The compiler distinguishes three points in time at which a TypeScript transformer can run. before transformers run on the TypeScript AST before the compiler downlevels it to JavaScript, and therefore still see type specific constructs such as enums or namespace declarations in their original form. after transformers run after downleveling, on the tree already translated into JavaScript compatible constructs. afterDeclarations finally runs exclusively on the generated .d.ts files, when declaration: true is enabled.

This ordering is crucial for choosing the right point in time. A TypeScript transformer that needs to detect enum declarations must run as a before transformer, because an enum has already been turned into an object literal with reverse mapping after downleveling and is no longer recognizable as an enum. Multiple transformers of the same category are applied one after another in the given order, with each transformer receiving as input the tree already modified by previous transformers.

7. Integrating into real build pipelines with ts-patch

The official TypeScript compiler tsc does not support custom transformers directly via tsconfig.json, because the compiler API does not provide a stable public interface for that over the command line. In practice tools such as ts-patch or the older ttypescript are used instead, which minimally patch the TypeScript compiler and in turn evaluate the plugins entry in the compilerOptions block of tsconfig.json. That way, a custom TypeScript transformer can be wired in through a regular tsc invocation or through the matching loader in Webpack and Vite pipelines.

After installing ts-patch, a one time ts-patch install call patches the local node_modules/typescript installation, so every subsequent tsc invocation automatically loads the configured plugins. This patch step ideally belongs in a prepare script in package.json, so it is automatically rerun after every npm install and does not accidentally get forgotten, for example after a dependency update that reinstalls typescript.


{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "plugins": [
      { "transform": "./transformers/strip-console-log.ts" },
      { "transform": "./transformers/inject-metadata.ts", "afterDeclarations": true }
    ]
  }
}

# Install ts-patch alongside TypeScript
npm install --save-dev ts-patch typescript

# Patch the local TypeScript installation to respect tsconfig "plugins"
npx ts-patch install

# package.json excerpt: re-patch automatically after every install
# "scripts": { "prepare": "ts-patch install -s" }

# Regular tsc invocation now runs the configured transformers
npx tsc --build

8. Making type-aware decisions with the TypeChecker

The real advantage of a TypeScript transformer over a purely syntactic tool such as Babel shows up as soon as decisions need to depend on the actual type of an expression. Via program.getTypeChecker() the transformer gains access to the same TypeChecker the regular compiler pass itself uses, and can therefore check, for example, whether a class implements a specific interface before automatically inserting metadata or decorator calls.

This type checking, however, requires the transformer to not work in isolation on a single file, but to be instantiated from the Program object it originates from, because the TypeChecker only produces meaningful results in the context of the complete program. A TypeScript transformer that needs type information is therefore typically implemented as a factory function that takes the Program as a parameter before returning the actual TransformerFactory.


import ts from "typescript";

// Type-aware transformer: only touches classes that implement a specific interface
function injectMetadataTransformer(program: ts.Program): ts.TransformerFactory<ts.SourceFile> {
  const checker = program.getTypeChecker();

  return (context) => (sourceFile) => {
    function visit(node: ts.Node): ts.Node {
      if (ts.isClassDeclaration(node) && node.name) {
        const type = checker.getTypeAtLocation(node);
        const implementsInjectable = type
          .getBaseTypes()
          ?.some((base) => checker.typeToString(base) === "Injectable");

        if (implementsInjectable) {
          // Attach a runtime marker based on a compile-time type check
          const marker = context.factory.createExpressionStatement(
            context.factory.createCallExpression(
              context.factory.createIdentifier("registerService"),
              undefined,
              [context.factory.createStringLiteral(node.name.text)]
            )
          );
          return [node, marker] as unknown as ts.Node;
        }
      }
      return ts.visitEachChild(node, visit, context);
    }
    return ts.visitNode(sourceFile, visit) as ts.SourceFile;
  };
}

9. Transformers compared to Babel plugins and ESLint rules

Three tools often compete for the same task: automatically changing or analyzing code. The following overview places a custom TypeScript transformer against Babel plugins and ESLint rules by capability and typical use.

Tool Type access Changes output Typical use
TypeScript Transformer Full TypeChecker available Yes, directly in the tsc build Type based code generation, metadata
Babel Plugin No type access Yes, purely syntactic Syntax transformations without types
ESLint Rule Optional via typed linting No, only messages Style rules, warnings, editor autofixes
ts-morph script Full TypeChecker available Yes, but outside the build One time code generation, refactoring scripts

A TypeScript transformer pays off once the change has to happen automatically and in a type aware way on every build, for example removing debug code or injecting metadata for a DI framework. For one time, interactive code changes, a ts-morph script is often the more pragmatic route, because it runs outside the build process and does not need to be integrated into every build pipeline.

Mironsoft

TypeScript tooling, build pipelines and Magento/Hyvä integrations

Custom build steps for your TypeScript code?

We build tailored TypeScript transformers for code generation, metadata injection and automated build optimizations, and wire them cleanly into your existing build pipeline via ts-patch.

Transformer development

Custom, type-aware transformers for code generation and build optimization

Build integration

ts-patch, Webpack and Vite loaders for your existing pipeline

Codegen tooling

Automated code generation with ts-morph and the Compiler API

10. Summary

A custom TypeScript transformer steps in exactly where linters and Babel plugins hit their limits: changes to the code that must be based on actual type information. Via ts.TransformerFactory, ts.visitEachChild and the immutable nodes from context.factory, you can precisely control which AST nodes get replaced, removed or newly created, without resorting to fragile text based replacements.

The distinction between before, after and afterDeclarations decides whether a TypeScript transformer still sees TypeScript specific constructs or already the downleveled JavaScript. Because tsc does not natively support transformers via the CLI, a tool such as ts-patch takes over patching the compiler and evaluating the plugins entry. Anyone who needs type information instantiates the transformer from a full Program and accesses, via getTypeChecker(), the same analysis the regular build itself uses.

Writing TypeScript Transformers - The Essentials at a Glance

Basic structure

ts.TransformerFactory<T> with context and an inner transform function. Traversal via ts.visitEachChild.

Creating nodes

Always use context.factory.createX or updateX, never mutate existing nodes directly.

Build integration

tsc does not support transformers natively via CLI. ts-patch patches the compiler and reads the plugins entry.

Type-aware logic

Use program.getTypeChecker() inside the transformer to base decisions on real types instead of plain syntax.

11. FAQ: Writing TypeScript Transformers

1What is a TypeScript transformer?
A function that changes the AST during the compiler pass, before JavaScript is emitted.
2Usable directly via tsc?
Only with an extra tool such as ts-patch, which patches the compiler and evaluates the tsconfig.json plugins entry.
3Why no direct node mutation?
Nodes are treated as immutable. Changes go through the factory API, which creates new nodes.
4createX vs. updateX?
createX always creates new. updateX returns the original node when nothing changed, saving effort.
5before vs. after transformer?
before sees the original TypeScript AST, after sees the already downleveled JavaScript tree.
6Access to type information?
Via program.getTypeChecker(), Program must be passed to the transformer factory as a parameter.
7Is ts-patch production ready?
Yes, patches only the local node_modules copy. A prepare script keeps the patch current after every install.
8Difference from a Babel plugin?
Babel works purely syntactically. A TypeScript transformer can factor in types via the TypeChecker.
9Insert new statements?
Yes, via factory.updateSourceFile with an extended statement list, or an array returned from visit.
10Worth it for small projects?
Usually not. The effort pays off only with recurring, type-aware code changes in larger codebases.