Are you familiar with the pain of manually tracking your RxJs subscriptions? Have you ever left one behind and only discovered it later? Or you've relied on the async pipe in your template, thinking you're covered, but after a while a feature request arrives that forces you to use a direct subscribe() inside your component class. Such a situation often points to questionable component architecture, but let's be realistic: occasionally it's the only pragmatic path forward.

Imagine a world where subscriptions no longer need to cross our minds at all.

Say goodbye to juggling subscription arrays.
Say goodbye to takeUntil(this.destroyed$)
Say goodbye to subscription.add()
Say goodbye to all that anxiety, right?

This can become reality thanks to TypeScript Transformers running during the build phase.

Before we get too excited—though I was certainly thrilled—I must admit that such black magic code generation comes with significant caveats. There are scenarios, as even unsubscribing can be a mistake. Therefore, keep in mind that this demonstration is chiefly educational.

What are TypeScript transformers?

TypeScript Transformers let us inject ourselves into the TypeScript compilation pipeline and modify the resulting Abstract Syntax Tree (AST).

This means we're able to alter source code at build time. In this guide, for instance, we'll leverage this capability to:

  • locate every class adorned with a @Component() decorator
  • locate every invocation of RxJs's subscribe()
  • create methods such as ngOnDestroy
  • append logic to pre-existing methods
  • etc.

This mechanism is incredibly potent and is at the core of how Angular itself compiles your code.

To better understand an AST, play with the live example over at astexplorer.net. The tool's left pane shows the source of the TestComponent class, while the right pane displays its parsed AST. Edit the source and watch the AST update instantly. This explorer will prove invaluable later when we need to figure out how to craft the transformer logic for modifying existing code or generating fresh nodes.

But for now, let's examine a minimal transformer template:

function simpleTransformerFactory(context: ts.TransformationContext) {
  // Visit each node and call the function 'visit' from below
  return (rootNode: ts.SourceFile) => ts.visitNode(rootNode, visit);
  
  function visit(node: ts.Node): ts.Node {
    if (ts.isClassDeclaration(node)) {
      console.log('Found class node! ', node.name.escapedText);
    }
    
    // Visit each Child-Node recursively with the same visit function
    return ts.visitEachChild(node, visit, context);
  }
}
// Typings: typescript.d.ts

/**
 * A function that is used to initialize and return a `Transformer` callback, which in turn
 * will be used to transform one or more nodes.
 */
type TransformerFactory<T extends Node> = (context: TransformationContext) => Transformer<T>;

/**
 * A function that transforms a node.
 */
type Transformer<T extends Node> = (node: T) => T;

/**
 * A function that accepts and possibly transforms a node.
 */
type Visitor = (node: Node) => VisitResult<Node>;
type VisitResult<T extends Node> = T | T[] | undefined;

In our demo, simpleTransformerFactory serves as the TransformerFactory, handing back a Transformer.

As the TypeScript typings (the second code block) reveal, a transformer is essentially a function that receives a Node and outputs a Node.

Within the earlier snippet where we only print every class name, we traverse the TypeScript AST (Abstract Syntax Tree) using the visitor pattern, ensuring that each node in the AST gets visited.

Such a node might represent:

  • CallExpression like this.click$.subscribe()
  • BinaryExpression like this.subscription = this.click$.subscribe()
  • ClassDelcaration like class Foo {}
  • ImportDeclaration like import {Component} from '@angular/core'
  • VariableStatement like const a = 1 + 2
  • MethodDeclaration

Generating the unsubscribe code

Our custom transformer must run before any of Angular's own transformers, locating every subscribe invocation inside components and producing code that automatically unsubscribes within the ngOnDestroy hook.

Credit goes to Manfred Steyer and David Kingma for making that goal fairly straightforward.

Integrating our custom transformer into the Angular-CLI build pipeline is possible via the ngx-build-plus library and its plugin mechanism. Inside a plugin, the AngularCompilerPlugin is accessible, allowing us to append our transformer to the “private” transformers array.

import { unsubscribeTransformerFactory } from './transformer/unsubscribe.transformer';
import { AngularCompilerPlugin } from '@ngtools/webpack';

function findAngularCompilerPlugin(webpackCfg): AngularCompilerPlugin | null {
  return webpackCfg.plugins.find(plugin =>  plugin instanceof AngularCompilerPlugin);
}

// The AngularCompilerPlugin has nog public API to add transformations, user private API _transformers instead.
function addTransformerToAngularCompilerPlugin(acp, transformer): void {
  acp._transformers = [transformer, ...acp._transformers];
}

export default {
  pre() {},

  // This hook is used to manipulate the webpack configuration
  config(cfg) {
    // Find the AngularCompilerPlugin in the webpack configuration
    const angularCompilerPlugin = findAngularCompilerPlugin(cfg);

    if (!angularCompilerPlugin) {
      console.error('Could not inject the typescript transformer: Webpack AngularCompilerPlugin not found');
      return;
    }

    addTransformerToAngularCompilerPlugin(angularCompilerPlugin, unsubscribeTransformerFactory(angularCompilerPlugin));
    return cfg;
  },

  post() {
  }
};

The essential logic behind generating unsubscribe calls lives in the Typescript Transformer, as illustrated by the snippet below. This excerpt focuses on the core operations, omitting the transformer’s full implementation.

export function unsubscribeTransformerFactory(acp: AngularCompilerPlugin) {
  return (context: ts.TransformationContext) => {

    const checker = acp.typeChecker;

    return (rootNode: ts.SourceFile) => {

      let withinComponent = false;
      let containsSubscribe = false;

      function visit(node: ts.Node): ts.Node {

        // 1. 
        if (ts.isClassDeclaration(node) && isComponent(node)) {
          withinComponent = true;
        
          // 2. Visit the child nodes of the class to find all subscriptions first
          const newNode = ts.visitEachChild(node, visit, context);

          if (containsSubscribe) {
            // 4. Create the subscriptions array
            newNode.members = ts.createNodeArray([...newNode.members, createSubscriptionsArray()]);
  
            // 5. Create the ngOnDestroyMethod if not there 
            if (!hasNgOnDestroyMethod(node)) {
              newNode.members = ts.createNodeArray([...newNode.members, createNgOnDestroyMethod()]);
            }
 
            // 6. Create the unsubscribe loop in the body of the ngOnDestroyMethod
            const ngOnDestroyMethod = getNgOnDestroyMethod(newNode);
            ngOnDestroyMethod.body.statements = ts.createNodeArray([...ngOnDestroyMethod.body.statements, createUnsubscribeStatement()]);
          }

          withinComponent = false;
          containsSubscribe = false;

          return newNode;
        } 

        // 3.
        if (isSubscribeExpression(node, checker) && withinComponent) {
          containsSubscribe = true;
          return wrapSubscribe(node, visit, context);
        }
      
        return ts.visitEachChild(node, visit, context);
      }

      return ts.visitNode(rootNode, visit);
    };
  };
}

Step 1
First, confirm that the current context is a component class. If this is the case, store that detail in a variable called withinComponent, since only subscribe() calls inside a component should be modified.

Step 2
Next, invoke ts.visitEachChildNode() right away to locate all subscription calls inside this component.

Step 3
Whenever we encounter a subscribe() expression during this traversal, we enclose it in a this.subscriptions.push(subsribe-expression) statement.

Step 4
In case such a subscribe expression was present in the component’s child nodes, the subscriptions array can then be added.

Step 5
After that, we look for the ngOnDestroy method, generating one if it’s absent.

Step6
Finally, the ngOnDestroy method’s body gets augmented with the cleanup logic: this.subscriptions.forEach(s => s.unsubscribe())

Full Source

Here is the entire source code for the unsubscribe transformer. I won’t dive into the specifics of the Typescript Compiler API, as that would exceed the scope of this article.

My method was essentially based on trial and error. I would paste pre-existing source code into astexplorer.net and then attempt to construct the corresponding AST programmatically.

In the summary part, I’ll provide a few helpful references to related transformer articles.

import * as ts from 'typescript';
import {AngularCompilerPlugin} from '@ngtools/webpack';

// Build with:
// Terminal 1: tsc --skipLibCheck --module umd -w
// Terminal 2: ng build --aot --plugin ~dist/out-tsc/plugins.js
// Terminal 3: ng build --plugin ~dist/out-tsc/plugins.js

const rxjsTypes = [
  'Observable',
  'BehaviorSubject',
  'Subject',
  'ReplaySubject',
  'AsyncSubject'
];

/**
 * 
 * ExpressionStatement
 *  -- CallExpression
 *     -- PropertyAccessExpression
 * 
 * 
 * looking into:
 *    - call expressions within a
 *    - expression statement only
 *    - that wraps another call expression where a property is called with subscribe 
 *    - and the type is contained in rxjsTypes
 * 
 */
function isSubscribeExpression(node: ts.Node, checker: ts.TypeChecker): node is ts.CallExpression {
  // ts.isBinaryExpression
  // ts.isCallExpression
  // ts.isClassDeclaration
  // ts.is

  return ts.isCallExpression(node) &&
    node.parent && ts.isExpressionStatement(node.parent) &&
    ts.isPropertyAccessExpression(node.expression) &&
    node.expression.name.text === 'subscribe' &&
    rxjsTypes.includes(getTypeAsString(node, checker));
} 

function getTypeAsString(node: ts.CallExpression, checker: ts.TypeChecker) {
  const type: ts.Type = checker.getTypeAtLocation((node.expression as ts.PropertyAccessExpression | ts.CallExpression).expression);
  console.log('TYPE: ', type.symbol.name);
  return type.symbol.name;
}

/**
 * Takes a subscibe call expression and wraps it with:
 * this.subscriptions.push(node)
 */
function wrapSubscribe(node: ts.CallExpression, visit, context) {
  return ts.createCall(
    ts.createPropertyAccess(
      ts.createPropertyAccess(ts.createThis(), 'subscriptions'),
      'push'
    ),
    undefined,
    [ts.visitEachChild(node, visit, context)]
  );
}

function logComponentFound(node: ts.ClassDeclaration) {
  console.log('Found component: ', node.name.escapedText);
}

function isComponent(node: ts.ClassDeclaration) {
  return node.decorators && node.decorators.filter(d => d.getFullText().trim().startsWith('@Component')).length > 0;
}

/**
 * creates an empty array property:
 * subscriptions = [];
 */
function createSubscriptionsArray() {
  return ts.createProperty(
    undefined, 
    undefined, 
    'subscriptions', 
    undefined, 
    undefined, 
    ts.createArrayLiteral()
  );
}

function isNgOnDestroyMethod(node: ts.ClassElement): node is ts.MethodDeclaration {
  return ts.isMethodDeclaration(node) && (node.name as ts.Identifier).text == 'ngOnDestroy';
}

function hasNgOnDestroyMethod(node: ts.ClassDeclaration) {
  return node.members.filter(node => isNgOnDestroyMethod(node)).length > 0;
}

function getNgOnDestroyMethod(node: ts.ClassDeclaration) {
  const n = node.members
    .filter(node => isNgOnDestroyMethod(node))
    .map(node => node as ts.MethodDeclaration);
   return n[0];
}

function createNgOnDestroyMethod() {
  return ts.createMethod(
    undefined,
    undefined,
    undefined,
    'ngOnDestroy',
    undefined,
    [],
    [],
    undefined,
    ts.createBlock([], true)
  );
}

function createUnsubscribeStatement() {
  return ts.createExpressionStatement(
    ts.createCall(
      ts.createPropertyAccess(
        ts.createPropertyAccess(ts.createThis(), 'subscriptions'),
        'forEach'
      ),
      undefined,
      [
        ts.createArrowFunction(
          undefined,
          undefined,
          [
            ts.createParameter(undefined, undefined, undefined, 'sub', undefined, undefined, undefined)
          ],
          undefined,
          ts.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
          ts.createCall(
            ts.createPropertyAccess(ts.createIdentifier('sub'), 'unsubscribe'),
            undefined,
            []
          )
        )
      ]
    )
  );
}

export function unsubscribeTransformerFactory(acp: AngularCompilerPlugin) {
  return (context: ts.TransformationContext) => {

    const checker = acp.typeChecker;

    return (rootNode: ts.SourceFile) => {

      let withinComponent = false;
      let containsSubscribe = false;

      function visit(node: ts.Node): ts.Node {

        // 1. 
        if (ts.isClassDeclaration(node) && isComponent(node)) {
          withinComponent = true;
        
          // 2. Visit the child nodes of the class to find all subscriptions first
          const newNode = ts.visitEachChild(node, visit, context);

          if (containsSubscribe) {
            // 4. Create the subscriptions array
            newNode.members = ts.createNodeArray([...newNode.members, createSubscriptionsArray()]);
  
            // 5. Create the ngOnDestroyMethod if not there 
            if (!hasNgOnDestroyMethod(node)) {
              newNode.members = ts.createNodeArray([...newNode.members, createNgOnDestroyMethod()]);
            }
 
            // 6. Create the unsubscribe loop in the body of the ngOnDestroyMethod
            const ngOnDestroyMethod = getNgOnDestroyMethod(newNode);
            ngOnDestroyMethod.body.statements = ts.createNodeArray([...ngOnDestroyMethod.body.statements, createUnsubscribeStatement()]);
          }

          withinComponent = false;
          containsSubscribe = false;

          return newNode;
        } 

        // 3.
        if (isSubscribeExpression(node, checker) && withinComponent) {
          containsSubscribe = true;
          return wrapSubscribe(node, visit, context);
        }
      
        return ts.visitEachChild(node, visit, context);
      }

      return ts.visitNode(rootNode, visit);
    };
  };
}

Kick things off from the project root by issuing this command first:

  • tsc --skipLibCheck --module umd — this compiles both transformer.ts and plugins.ts
  • next, fire up ng build --plugin ~dist/out-tsc/plugins.js to run Angular’s build pipeline with our custom plugin attached; check the main.js file inside the dist folder for the output
  • if you want, serve it locally via ng serve --plugin ~dist/out-tsc/plugins.js

Consider a component where we deliberately leave subscriptions unhandled:

@Component({
  selector: 'app-test',
  templateUrl: './test.component.html',
  styleUrls: ['./test.component.scss']
})
export class TestComponent implements OnDestroy {
  title = 'Hello World';
  showHistory = true;

  be2 = new BehaviorSubject(1);

  constructor(private heroService: HeroService) {
    this.heroService.mySubject.subscribe(v => console.log(v));
    interval(1000).subscribe(val => console.log(val));
  }

  toggle() {
    this.showHistory = !this.showHistory;
  }

  ngOnInit() {
    this.be2.pipe(
      map(v => v)
    ).subscribe(v => console.log(v));
  }

  ngOnDestroy() {
    console.log('fooo');
  }
}

The snippet below is what Angular produces once the full transformation and build pipeline has completed:

var TestComponent = /** @class */ (function () {
    function TestComponent(heroService) {
        this.heroService = heroService;
        this.title = 'Version22: ' + VERSION;
        this.be2 = new rxjs__WEBPACK_IMPORTED_MODULE_1__["BehaviorSubject"](1);
        this.subscriptions = [];
        this.subscriptions.push(this.heroService.mySubject.subscribe(function (v) { return console.log(v); }));
        this.subscriptions.push(Object(rxjs__WEBPACK_IMPORTED_MODULE_1__["interval"])(1000).subscribe(function (val) { return console.log(val); }));
    }
    TestComponent.prototype.ngOnInit = function () {
        this.subscriptions.push(this.be2.pipe(Object(rxjs_operators__WEBPACK_IMPORTED_MODULE_3__["map"])(function (v) { return v; })).subscribe(function (v) { return console.log(v); }));
    };
    TestComponent.prototype.ngOnDestroy = function () {
        console.log('fooo');
        this.subscriptions.forEach(function (sub) { return sub.unsubscribe(); });
    };
    TestComponent = __decorate([
        Object(_angular_core__WEBPACK_IMPORTED_MODULE_0__["Component"])({
            selector: 'app-test',
            template: __webpack_require__(/*! ./test.component.html */ "./src/app/test.component.html"),
            styles: [__webpack_require__(/*! ./test.component.scss */ "./src/app/test.component.scss")]
        }),
        __metadata("design:paramtypes", [_hero_service__WEBPACK_IMPORTED_MODULE_2__["HeroService"]])
    ], TestComponent);
    return TestComponent;
}());

Did you manage to identify the handled subscriptions? ?

Summary

The Angular team keeps its transformer API private for a good reason—it's a strong hint that we shouldn't rely on it in our daily workflow.
While the unsubscribe transformer sounds appealing, it quickly reveals how complicated such a solution becomes, since making it truly robust would require accounting for numerous edge cases.

Nevertheless, a few ideas come to mind:
A custom JAM Stack transformer could be built to perform http requests during the build process.
Alternatively, the Typescript Compiler API might be used to auto-generate the TestBed statement for our tests, bundling all the necessary dependencies.

Further reading:

Converting Typescript decorators into static code by Craig Spence
This piece is quite valuable—the tsquery library it highlights makes AST access far easier, and it's worth exploring if you're into writing custom linters or transformers.

Do you know how Angular transforms your code? by Alexey Zuev
Here, you get a deep dive into how Angular employs Typescript Transformers at build time—there's plenty to pick up from the transformer implementations the Angular team has crafted.

Custom Typescript Transformer with Angular by David Kingma
This is an overlooked gem in my view—it walks you through creating your own transformers and wiring them into the Angular CLI build process.

Using the Compiler API
The official documentation for the Typescript Compiler

Wishing you a pleasant day. It's sunny here in Austria. Stay tuned ?

Here's the Github Repo for the example mentioned earlier.

You can find me on Twitter.