The Shift to Angular's Built-in Lifecycle Cleanup
Angular keeps shipping new capabilities, and one of the recent additions is the DestroyRef token. This utility exposes an onDestroy hook that fires precisely when a component or directive reaches its destruction point.
@Component({...})
class IAmAHookComponent {
constructor() {
inject(DestroyRef).onDestroy(() => {
// Have something to clean up before I go?
})
}
}
The OnDestroy interface has offered this for a while, but DestroyRef brings a crucial advantage: it pairs seamlessly with the takeUntilDestroyed function, unlocking a cleaner, more reactive approach to managing subscriptions.
Why the Existing Pattern Falls Short
Prior to takeUntilDestroyed, manually unsubscribing from an observable like stream$ required a fair amount of boilerplate and tracking.
@Component({...})
export class OldSchoolUnsubscribeComponent implements OnInit, OnDestroy {
_destroy: Subject<void> = new Subject<void>();
ngOnInit(): void {
stream$
.pipe(takeUntil(this._destroy))
.subscribe();
}
ngOnDestroy(): void {
this._destroy.next();
}
}
Now, with DestroyRef combined with takeUntilDestroyed, the same result is achieved with far less code and ceremony.
export class NewWayComponent implements OnInit {
_destroy: DestroyRef = inject(DestroyRef);
ngOnInit(): void {
_stream.pipe(takeUntilDestroyed(this._destroy)).subscribe();
}
}
Quite an improvement, wouldn't you say?
In the past, I relied on the excellent until-destroy library, which addressed the same problem using a decorator-based approach.
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
@UntilDestroy()
@Component({...})
export class UsingPackageComponent {
ngOnInit() {
_stream.pipe(untilDestroyed(this)).subscribe();
}
}
The logic is very similar. However, my preference leans towards leveraging the framework's built-in capabilities whenever possible. Since Angular now provides a first-party solution, I'm keen to trim that extra dependency from my node_modules
The Migration Path
These changes can be performed by hand, but a more efficient route is to write a script that automates the transformation. In a previous post, we looked at leveraging the TypeScript compiler API for such code modifications, and this is a perfect candidate for it. For today, though, we'll be working with the ts-morph package for a few reasons:
- It's a great learning opportunity.
- The standard TypeScript AST printer doesn't preserve your original code's formatting nuances, though you could run prettier afterward to restore it—a step that
ts-morphmakes unnecessary. - It provides a more ergonomic and accessible API for source code manipulation.
First, install the dependency with npm i ts-morph
Next, set up a project instance and filter the source files that are relevant to Angular components or directives.
import * as morph from "ts-morph";
const project = new morph.Project({
// Change it to select the project you want to update.
tsConfigFilePath: "./tsconfig.json",
});
// Get all the source files that match the angular pattern (e.g., "*.component.ts")
const files = project.getSourceFiles(
`/**/*.+(component|directive|pipe|service).ts`
);
Bear in mind that this demo focuses strictly on the basic usage pattern of until-destroy we outlined above. If your codebase additionally utilizes checkProperties, arrayName, or blackList options, you'll need to extend the script's logic to handle those edge cases.
Implementation Walkthrough
The strategy is straightforward: pull every file into ts-morph, then examine each class that lives inside those files.
// Iterate through each component file
for (const file of files) {
const classes = file.getDescendantsOfKind(morph.SyntaxKind.ClassDeclaration);
for (const clazz of classes) {
// Codemod Logic goes here
}
}
From there, you’ll want to filter out any classes that are not candidates for migration. A class gets skipped if it falls into any of these four categories:
- It carries no Angular-related decorator.
- It lacks the
UntilDestroydecorator entirely. - The
UntilDestroydecorator includes options. - The
untilDestroyedoperator never appears in the class body.
When the class has no Angular-related decorator.
for (const clazz of classes) {
{
// only migrate classes that have @Component, @Directive, @Pipe or @Injectable decorators
const angularDecorators = ["Component", "Directive", "Pipe", "Injectable"];
const angularClass = clazz.getDecorator(dec =>
angularDecorators.includes(dec.getName())
);
if (!angularClass) {
continue;
}
}
}
When the class misses the UntilDestroy decorator.
{
// @UntilDestroy() is our indication that the class needs to be migrated
const untilDestroyDecorator = clazz.getDecorator(
dec => dec.getName() === "UntilDestroy"
);
if (!untilDestroyDecorator) {
continue;
}
}
When the UntilDestroy decorator ships with options.
// if options is specified then skip this file
const [optionsArg] = untilDestroyDecorator?.getArguments() ?? [];
const haveOptions = (
optionsArg as morph.ObjectLiteralExpression
)?.getProperties().length;
if (haveOptions) {
continue;
}
To check the final condition, you’ll need to locate every CallExpression node inside the class and confirm whether each invocation resolves to a function named untilDestroyed.
{
// migrate untilDestroyed() to takeUntilDestroyed()
const untilDestroyedCalls = clazz
.getDescendantsOfKind(morph.SyntaxKind.CallExpression)
.filter(call => {
const identifier = call.getLastChildByKind(morph.SyntaxKind.Identifier);
return identifier?.getText() === "untilDestroyed";
});
if (!untilDestroyedCalls.length) {
continue;
}
}
Once the filters are in place, you can start modifying the code itself. An untilDestroyed call can exist in one of three locations: inside the constructor, inside a method, or directly in the class field initializer.
Inside the constructor.
@UntilDestroy()
@Injectable()
export class InboxService {
constructor() {
interval(1000).pipe(untilDestroyed(this)).subscribe();
}
}
Inside a method.
@UntilDestroy()
@Component({})
export class InboxComponent {
ngOnInit() {
interval(1000).pipe(untilDestroyed(this)).subscribe();
}
}
Directly in the class body.
@UntilDestroy()
@Component({})
export class HomeComponent {
subscription = fromEvent(document, "mousemove")
.pipe(untilDestroyed(this))
.subscribe();
}
So the next step is to iterate over the previously collected untilDestroyedCalls and determine, for each call, where it is situated. The modification logic branches based on that location.
// You need destroyRef in case takeUntilDestroyed(this._destroyRef) have been used
let doWeNeedDestroyRef = null;
for (const untilDestroyedCall of untilDestroyedCalls) {
const withinConstructor = untilDestroyedCall.getFirstAncestorByKind(
morph.SyntaxKind.Constructor
);
const withinMethod = untilDestroyedCall.getFirstAncestorByKind(
morph.SyntaxKind.MethodDeclaration
);
switch (true) {
case !!withinConstructor:
// takeUntilDestroyed if used within the constructor can auto-infer the destroyRef
// from the injection context
untilDestroyedCall.replaceWithText("takeUntilDestroyed()");
break;
case !!withinMethod:
// takeUntilDestroyed if used within a method needs to be passed the destroyRef
untilDestroyedCall.replaceWithText(
"takeUntilDestroyed(this._destroyRef)"
);
// set doWeNeedDestroyRef to true so that you can add the _destroyRef property
doWeNeedDestroyRef ??= true;
break;
default:
// Assuming the observable is declared directly in the class
// body so you treat it as if it were within the constructor
untilDestroyedCall.replaceWithText("takeUntilDestroyed()");
break;
}
}
You might have noticed the variable doWeNeedDestroyRef — it tracks whether the class requires a new _destroyRef property. If untilDestroyed shows up inside a method, then the class will need that injected property added.
{
// add Inject DestroyRef after last public property
if (doWeNeedDestroyRef) {
const lastPublicProperty = clazz
.getInstanceProperties()
.filter(prop => prop.getScope() === morph.Scope.Public);
clazz.insertProperty(lastPublicProperty.length, {
name: "_destroyRef",
scope: morph.Scope.Private,
initializer: "inject(DestroyRef)",
});
}
}
Alternatively, you can sidestep this check entirely by always pairing _destroyRef with takeUntilDestroyed.
Finally, you must strip out both the until-destroy import and the UntilDestroy decorator.
{
// ...
if (!untilDestroyedCalls.length) {
continue;
}
// remove @UntilDestroy() decorator
untilDestroyDecorator.remove();
}
Removing the until-destroy import.
{
// remove untilDestroyed import
const untilDestroyedImport = file.getImportDeclaration(
imp => imp.getModuleSpecifierValue() === "@ngneat/until-destroy"
);
untilDestroyedImport?.remove();
}
The last step is to verify that the imports for DestroyRef and takeUntilDestroyed are present, then write the file back to disk.
{
// add imports if needed
if (fileMigrated) {
const imports: [string, string[]][] = [
["@angular/core", ["inject", "DestroyRef"]],
["@angular/core/rxjs-interop", ["takeUntilDestroyed"]],
];
setImports(file, imports);
file.saveSync();
}
}
export function setImports(
sourceFile: morph.SourceFile,
imports: [string, string[]][]
): void {
imports.forEach(([moduleSpecifier, namedImports]) => {
const moduleSpecifierImport =
sourceFile
.getImportDeclarations()
.find(imp => imp.getModuleSpecifierValue() === moduleSpecifier) ??
sourceFile.addImportDeclaration({
moduleSpecifier,
});
const missingNamedImports = namedImports.filter(
namedImport =>
!moduleSpecifierImport
.getNamedImports()
.some(imp => imp.getName() === namedImport)
);
moduleSpecifierImport.addNamedImports(missingNamedImports);
});
}
Live Example
Demo & Complete Code: To run this code, open the terminal at the bottom and run "npx ts-node ./index.ts"
Check the examples folder to see the migration output for real sample files.
Final Thoughts
The ts-morph API is clean and straightforward, but it can still trip you up if the TypeScript compiler API is unfamiliar territory. A good starting point is the Gentle Introduction To Typescript Compiler API, which explains the underlying concepts in plain terms.
One practical note: always run the script against a clean repository with all changes committed. The tool rewrites files in place, so you want an easy way to revert if something goes wrong.
