Table of Contents
This blog post is part of an article series.
- Part I: Generating Custom Code With The Angular CLI And Schematics
- Part II: Automatically Updating Angular Modules With Schematics And The CLI
- Part III: Extending Existing Code With The TypeScript Compiler API
- Part IV: Frictionless Library Setup with the Angular CLI and Schematics
- Part V: Seamlessly Updating your Angular Libraries with ng update
In the two prior installments of this series, I demonstrated how Schematics can be used to generate custom code through the Angular CLI and how it can refresh an NgModule to declare the newly generated components. That last task turned out to be relatively straightforward, since the CLI already performs the same operation, which means ready-made helper functions exist for our use.
However, it's not always the case that convenient helpers are available. When they aren't, we have to handle the complex work ourselves, which is precisely the focus here: directly editing existing source code in a safe, reliable manner.
A closer look at the helper utilities from the previous article reveals they rely on the TypeScript Compiler API, which, among other things, provides a syntax tree representation for TypeScript files. By walking this tree and examining its nodes, we can inspect existing code and determine exactly where a change should be applied.
With this technique, the current post expands upon the schematic from the last article by injecting the generated Service into the AppComponent so it can be configured there:
[...]
import { SideMenuService } from './core/side-menu/side-menu.service';
@Component({ [...] })
export class AppComponent {
constructor(
private sideMenuService: SideMenuService) {
// sideMenuService.show = true;
}
}
In my view, delivering this kind of boilerplate for library configuration can make initial adoption noticeably smoother. Still, keep in mind that this basic example stands in for many scenarios where altering existing code adds significant convenience.
The source code for the examples discussed here is available in this GitHub repository.
Schematics is currently an Angular Labs project. Its public API is experimental and can change in future.
![]()
Navigating a Syntax Tree with the TypeScript Compiler API
To become comfortable with the TypeScript Compiler API, we'll begin with a straightforward NodeJS example that showcases its core usage. TypeScript itself is the only prerequisite. Since this will run inside a basic NodeJS app, I'll also install the corresponding type definitions. In a fresh directory, these commands will get us going:
npm init
npm install typescript --save
npm install @types/node --save-dev
Beyond that, a tsconfig.json with suitable compiler options is required:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["dom", "es2017"],
"moduleResolution": "node"
}
}
With that foundation in place, we can proceed to our first experiment with the Compiler CLI. Let's create an index.ts file:
import * as ts from 'typescript';
import * as fs from 'fs';
function showTree(node: ts.Node, indent: string = ' '): void {
console.log(indent + ts.SyntaxKind[node.kind]);
if (node.getChildCount() === 0) {
console.log(indent + ' Text: ' + node.getText());
}
for(let child of node.getChildren()) {
showTree(child, indent + ' ');
}
}
let buffer = fs.readFileSync('demo.ts');
let content = buffer.toString('utf-8');
let node = ts.createSourceFile('demo.ts', content, ts.ScriptTarget.Latest, true);
showTree(node);
The showTree function performs a recursive traversal of the syntax tree, starting from the given node. It logs each node's kind property to the console, which reveals whether the node represents, say, a class name, a constructor, or a parameter list. When a node has no children, the program also prints its text content, such as the class name it denotes. The process then repeats for every child node, increasing the indentation level.
At the end, the program loads a TypeScript file and builds a SourceFile object from its contents. Because SourceFile itself is a node, it can be passed to showTree.
We also need the demo.ts file that the application reads. For simplicity, let's use the following minimal class:
class Demo {
constructor(otherDemo: Demo) {}
}
To compile and execute the application, use these commands:
tsc index.ts
node index.js
Obviously, setting up a npm script for this would be sensible.
Once running, the application should present the syntax tree as follows:
SourceFile
SyntaxList
ClassDeclaration
ClassKeyword
Text: class
Identifier
Text: Demo
FirstPunctuation
Text: {
SyntaxList
Constructor
ConstructorKeyword
Text: constructor
OpenParenToken
Text: (
SyntaxList
Parameter
Identifier
Text: otherDemo
ColonToken
Text: :
TypeReference
Identifier
Text: Demo
CloseParenToken
Text: )
Block
FirstPunctuation
Text: {
SyntaxList
Text:
CloseBraceToken
Text: }
CloseBraceToken
Text: }
EndOfFileToken
Text:
Spend a moment examining this tree. Every component of our demo.ts is represented by a corresponding node. For instance, there's a ClassDeclaration node for our class, which contains a ClassKeyword and an Identifier bearing the text Demo. You'll also notice a Constructor node with children that represent its various parts, including a SyntaxList holding a subtree for the constructor argument otherDemo.
Combining the insights gained from this example with what we already understand about Schematics from earlier posts, we have the full toolkit to implement the goal described at the outset. The following sections detail the steps involved.
Supplying Essential Data
When crafting a Schematics rule, a sensible initial move is to enumerate all the data it requires and encapsulate that in a class. Here's what that class looks like for our case:
export interface AddInjectionContext {
appComponentFileName: string;
// e. g. /src/app/app.component.ts
relativeServiceFileName: string;
// e. g. ./core/side-menu/side-menu.service
serviceName: string;
// e. g. SideMenuService
}
To acquire this data, we'll create a function named createAddInjectionContext:
function createAddInjectionContext(options: ModuleOptions): AddInjectionContext {
let appComponentFileName = findFileByName('app.component.ts', options.path || '/', host);
let destinationPath = constructDestinationPath(options);
let serviceName = classify(<span class="hljs-subst">${options.name}</span>Service);
let serviceFileName = join(normalize(destinationPath), <span class="hljs-subst">${dasherize(options.name)}</span>.service);
let relativeServiceFileName = buildRelativePath(appComponentFileName, serviceFileName);
return {
appComponentFileName,
relativeServiceFileName,
serviceName
}
}
function findFileByName(file: string, path: string, host: Tree): string {
let dir: DirEntry | null = host.getDir(path);
while(dir) {
let appComponentFileName = dir.path + '/' + file;
if (host.exists(appComponentFileName)) {
return appComponentFileName;
}
dir = dir.parent;
}
throw new SchematicsException(File <span class="hljs-subst">${file}</span> not found in <span class="hljs-subst">${path}</span> or one of its anchestors);
}
As shown, createAddInjectionContext accepts an instance of the ModuleOptions class, which is part of the utils that Schematics provides and represents the parameters passing through the CLI. The three necessary fields are derived from that instance. To determine where the generated files should reside, it employs the custom helper constructDestinationPath:
export function constructDestinationPath(options: ModuleOptions): string {
return '/' + (options.sourceDir? options.sourceDir + '/' : '') + (options.path || '')
+ (options.flat ? '' : '/' + dasherize(options.name));
}
Additionally, it makes use of several helper functions that ship with Schematics:
classify: Generates a class name, for instance convertingside-menuintoSideMenu.normalize: Standardizes a path to account for platform-specific characters like\on Windows.dasherize: Transforms a string into Kebab case, e.g., turningSideMenuintoside-menu.join: Concatenates two paths together.buildRelativePath: Creates a relative path pointing from the first absolute path argument to the second one.
It's worth mentioning that some of the helpers used here aren't exposed through the public API. To avoid issues from potential breaking changes, I've duplicated the relevant files. Further discussion of this quirk appears in my earlier post on the subject.
Generating a New Constructor
If the AppComponent lacks a constructor entirely, we need to fabricate one. The Schematics approach is to produce a Change object that describes this alteration. For this job, I've written a function called createConstructorForInjection. Even though it's somewhat lengthy, largely due to the necessary null/undefined checks, the logic itself is straightforward:
function createConstructorForInjection(context: AddInjectionContext, nodes: ts.Node[], options: ModuleOptions): Change {
let classNode = nodes.find(n => n.kind === ts.SyntaxKind.ClassKeyword);
if (!classNode) {
throw new SchematicsException(expected class in <span class="hljs-subst">${context.appComponentFileName}</span>);
}
if (!classNode.parent) {
throw new SchematicsException(expected constructor in <span class="hljs-subst">${context.appComponentFileName}</span> to have a parent node);
}
let siblings = classNode.parent.getChildren();
let classIndex = siblings.indexOf(classNode);
siblings = siblings.slice(classIndex);
let classIdentifierNode = siblings.find(n => n.kind === ts.SyntaxKind.Identifier);
if (!classIdentifierNode) {
throw new SchematicsException(expected class in <span class="hljs-subst">${context.appComponentFileName}</span> to have an identifier);
}
if (classIdentifierNode.getText() !== 'AppComponent') {
throw new SchematicsException(expected first class in <span class="hljs-subst">${context.appComponentFileName}</span> to have the name AppComponent);
}
// Find opening cury braces (FirstPunctuation means '{' here).
let curlyNodeIndex = siblings.findIndex(n => n.kind === ts.SyntaxKind.FirstPunctuation);
siblings = siblings.slice(curlyNodeIndex);
let listNode = siblings.find(n => n.kind === ts.SyntaxKind.SyntaxList);
if (!listNode) {
throw new SchematicsException(expected first class in <span class="hljs-subst">${context.appComponentFileName}</span> to have a body);
}
let toAdd = `
constructor(private ${camelize(context.serviceName)}: ${classify(context.serviceName)}) {
// ${camelize(context.serviceName)}.show = true;
}
`;
return new InsertChange(context.appComponentFileName, listNode.pos+1, toAdd);
}
The nodes parameter holds a flattened array of all nodes within the syntax tree. This format is also utilized by some of Schematics' built-in rules and simplifies searching through the tree with array methods. The function scans for the first node of type ClassKeyword, which corresponds to the class keyword itself. Cross-reference this with the syntax tree shown in the initial example above.
Following that, it obtains an array of the ClassKeyword's siblings (i.e., its parent's children) and scans from left to right to pinpoint a suitable location for the new constructor. To move left to right, it repeatedly truncates everything to the left of the current position using slice. Honestly, this isn't optimal from a performance standpoint, but it should be sufficiently fast, and I believe it enhances code readability.
Using this method, the function proceeds rightward until it encounters a SyntaxList (representing the class body) that follows a FirstPunctuation node (in this case, the { character), which itself comes after an Identifier (the class name). It then leverages the SyntaxList's position to craft an InsertChange object indicating where the constructor should be placed.
One could also examine the class body to find a more refined spot for the constructor—say, between property declarations and method declarations—but I've set that aside for the sake of simplicity and clarity in this demo.
Adding a Constructor Parameter
When a constructor already exists, we must append another argument to accommodate our service. The following function handles this task. Along with other parameters, it accepts the node representing the constructor, which you can also match against the syntax tree from the first example above.
function addConstructorArgument(context: AddInjectionContext, ctorNode: ts.Node, options: ModuleOptions): Change {
let siblings = ctorNode.getChildren();
let parameterListNode = siblings.find(n => n.kind === ts.SyntaxKind.SyntaxList);
if (!parameterListNode) {
throw new SchematicsException(expected constructor in <span class="hljs-subst">${context.appComponentFileName}</span> to have a parameter list);
}
let parameterNodes = parameterListNode.getChildren();
let paramNode = parameterNodes.find(p => {
let typeNode = findSuccessor(p, [ts.SyntaxKind.TypeReference, ts.SyntaxKind.Identifier]);
if (!typeNode) return false;
return typeNode.getText() === context.serviceName;
});
// There is already a respective constructor argument --> nothing to do for us here ...
if (paramNode) return new NoopChange();
// Is the new argument the first one?
if (!paramNode && parameterNodes.length == 0) {
let toAdd = private <span class="hljs-subst">${camelize(context.serviceName)}</span>: <span class="hljs-subst">${classify(context.serviceName)}</span>;
return new InsertChange(context.appComponentFileName, parameterListNode.pos, toAdd);
}
else if (!paramNode && parameterNodes.length > 0) {
let toAdd = `,
private ${camelize(context.serviceName)}: ${classify(context.serviceName)}`;
let lastParameter = parameterNodes[parameterNodes.length-1];
return new InsertChange(context.appComponentFileName, lastParameter.end, toAdd);
}
return new NoopChange();
}
This function fetches all child nodes of the constructor and seeks out a SyntaxList (the parameter list) that contains a TypeReference child, which in turn has an Identifier child. For this, it relies on the findSuccessor helper shown below. The resulting identifier holds the type of the argument in question. If an argument referencing our service's type already exists, no action is needed. Otherwise, the function checks whether we're inserting the first argument or a subsequent one, locates the correct position for the new argument in either case, and returns a corresponding InsertChange object for the required modification.
function findSuccessor(node: ts.Node, searchPath: ts.SyntaxKind[] ) {
let children = node.getChildren();
let next: ts.Node | undefined = undefined;
for(let syntaxKind of searchPath) {
next = children.find(n => n.kind == syntaxKind);
if (!next) return null;
children = next.getChildren();
}
return next;
}
Choosing Between Creation and Modification
Here's the encouraging part: the tough work is behind us. What remains is a function to determine which of the two alternatives—adding a constructor or modifying one—is appropriate:
function buildInjectionChanges(context: AddInjectionContext, host: Tree, options: ModuleOptions): Change[] {
let text = host.read(context.appComponentFileName);
if (!text) throw new SchematicsException(File <span class="hljs-subst">${options.module}</span> does not exist.);
let sourceText = text.toString('utf-8');
let sourceFile = ts.createSourceFile(context.appComponentFileName, sourceText, ts.ScriptTarget.Latest, true);
let nodes = getSourceNodes(sourceFile);
let ctorNode = nodes.find(n => n.kind == ts.SyntaxKind.Constructor);
let constructorChange: Change;
if (!ctorNode) {
// No constructor found
constructorChange = createConstructorForInjection(context, nodes, options);
}
else {
constructorChange = addConstructorArgument(context, ctorNode, options);
}
return [
constructorChange,
insertImport(sourceFile, context.appComponentFileName, context.serviceName, context.relativeServiceFileName)
];
}
Just like the first example in this post, it uses the TypeScript Compiler API to generate a SourceFile object for the file containing the AppComponent. It then relies on Schematics' getSourceNodes function to traverse the tree and produce a flat array of all nodes. Those nodes are searched to locate a constructor. If none is found, createConstructorForInjection is invoked to build a Change object; otherwise, addConstructorArgument takes over. In the end, the function returns this Change alongside another one generated by insertImport, also provided by Schematics, which inserts the required import statement at the top of the TypeScript file.
Be aware that the sequence of these two changes is critical, as they add lines to the source file, which alters the position information stored in the node objects.
Bringing Everything Together
Now, the only missing piece is a factory function for a rule that calls buildInjectionChanges and applies the returned changes:
export function injectServiceIntoAppComponent(options: ModuleOptions): Rule {
return (host: Tree) => {
let context = createAddInjectionContext(options);
let changes = buildInjectionChanges(context, host, options);
const declarationRecorder = host.beginUpdate(context.appComponentFileName);
for (let change of changes) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(declarationRecorder);
return host;
};
};
This function accepts the ModuleOptions containing the parameters passed from the CLI and returns a Rule function. It constructs the context object with the key data and hands off to buildInjectionChanges. The received rules are then iterated over and executed.
Incorporating the Rule into the Schematic
To ensure our new injectServiceIntoAppComponent rule gets invoked, we need to call it within its index.ts:
[...]
export default function (options: MenuOptions): Rule {
return (host: Tree, context: SchematicContext) => {
[...]
const rule = chain([
branchAndMerge(chain([
mergeWith(templateSource),
addDeclarationToNgModule(options, options.export),
injectServiceIntoAppComponent(options)
]))
]);
return rule(host, context);
}
}
Validating the Enhanced Schematic
Once the modifications are in place, build the Schematic and transfer all its files into the node_modules directory of your sample project. Following the pattern from the previous article, the destination is node_modules/nav. Be careful to omit the Schematic Collection's own node_modules folder from the copy, avoiding a nested structure like node_modules/nav/node_modules.
From the root of the example application, execute the Schematic:
ng g nav:menu side-menu --menu-service --export
The output includes the generated SideMenu component and also registers its corresponding service within the AppComponent:
import { Component } from '@angular/core';
import { OnChanges, OnInit } from '@angular/core';
import { SideMenuService } from './core/side-menu/side-menu.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
constructor(private sideMenuService: SideMenuService) {
// sideMenuService.show = true;
}
title = 'app';
}
