Brian Love
Google Developer Expert in Angular, software engineer and skier located in Denver, CO

NG Add Schematic

Reading time ~17 minutes

Learn how to build an Angular schematic that adds a library.

Getting Started Tutorial

If you’re brand new to Angular Schematics then I suggest you start with my Angular Schematics Tutorial.

AngularFire Schematics

Installing AngularFire is easy:

  1. Use npm or yarn to install both the firebase and @angular/fire modules.
  2. Add your Firebase project’s configuration to the Angular environment.ts file.
  3. Import the AngularFire module into the root module.

While this is an easy installation path, I thought it would be fun to tackle creating a schematic that does all of this for you, including prompting for your Firebase configuration.

ng add

Try it out:

$ ng new demo
$ cd demo
$ ng add angular-fire-schematics

Demo

ng add angular-fire-schematics

Download

Goals

My goal is that you will learn:

  • How to create a schematic that add’s a library to an Angular project (application or library).
  • The beginnings of working with the TypeScript abstract syntax tree (AST).
  • How to commit file updates to a file in the Tree.

Our goal is to build a schematic that can be added using the Angular CLI.

Create Schematic Collection

If you haven’t installed the schematics CLI then do that first via npm or yarn:

$ npm install -g @angular-devkit/schematics-cli
$ yarn add -g @angular-devkit/schematics-cli

The first step is to create a new schematics collection using the blank schematic:

$ schematics blank --name=angular-fire-schematics

This will create a new angular-fire-schematics directory, add the necessary files and folders to build a schematic, and then install the necessary dependencies.

Let’s initialize a Git repository for our project:

$ cd angular-fire-schematics
$ git init

Go ahead and open the project in your favorite editor or IDE. I’ll be using VS Code:

$ code angular-fire-schematics

Create ng-add Directory

Let’s clean up our src directory real quick:

$ rm -rf src/angular-fire-schematics
$ mkdir src/ng-add
$ touch src/ng-add/schema.json
$ touch src/ng-add/index.ts

Add Schematic to Collection

Open the src/collection.json file and add the ng-add schematic to the collection:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Adds Angular Firebase to the application without affecting any templates",
      "factory": "./ng-add/index",
      "schema": "./ng-add/schema.json",
      "aliases": ["install"]
    }
  }
}

I’ve specified the following properties:

  • The description of the schematic.
  • The factory function that will be executed as the “entry” point of the schematic. In this case I am exporting a defult function from the src/ng-add/index.js file.
  • The schema for the schematic.
  • The aliases array that declares aliases for our schematic. This is why you can generate a component using the shorthand “c” when using the Angular CLI.

Note, if you do not export a default function you can use the following syntax where we specify the function to invoke after a pound ( # ) symbol: "factory": "./ng-add/index#functionName.

Define Schematic Schema

Next, define the schema for the ng-add schematic. The schema will include the new x-prompt property that was introduced in v7. This will prompt the user to enter their Firebase configuration.

Create a new file src/ng-add/schema.json:

{
  "$schema": "http://json-schema.org/schema",
  "id": "angular-firebase-schematic-ng-add",
  "title": "Angular Firebase ng-add schematic",
  "type": "object",
  "properties": {
    "project": {
      "type": "string",
      "description": "The name of the project.",
      "$default": {
        "$source": "projectName"
      }
    },
    "apiKey": {
      "type": "string",
      "default": "",
      "description": "Your Firebase API key",
      "x-prompt": "What is your project's apiKey?"
    },
    "authDomain": {
      "type": "string",
      "default": "",
      "description": "Your Firebase domain",
      "x-prompt": "What is your project's authDomain?"
    },
    "databaseURL": {
      "type": "string",
      "default": "",
      "description": "Your Firebase database URL",
      "x-prompt": "What is your project's databaseURL?"
    },
    "projectId": {
      "type": "string",
      "default": "",
      "description": "Your Firebase project ID",
      "x-prompt": "What is your project's id?"
    },
    "storageBucket": {
      "type": "string",
      "default": "",
      "description": "Your Firebase storage bucket",
      "x-prompt": "What is your project's storageBucket?"
    },
    "messagingSenderId": {
      "type": "string",
      "default": "",
      "description": "Your Firebase message sender ID",
      "x-prompt": "What is your project's messagingSenderId?"
    }
  },
  "required": [],
  "additionalProperties": false
}

For each option we specify the:

  • TypeScript type.
  • A default value.
  • The description.
  • The x-prompt value with the text to prompt the user when the option is not specified on the command line

Define Schema

I like to strongly type the options that the user can specify. This provides me with type safety when working with the options argument to the entry function of my schematic.

Create a new src/app/schema.ts file and export the Schema interface:

export interface Schema {
  /** Firebase API key. */
  apiKey: string;

  /** Firebase authorized domain. */
  authDomain: string;

  /** Firebase db URL. */
  databaseURL: string;

  /** Name of the project to target. */
  project: string;

  /** Firebase project ID. */
  projectId: string;

  /** Firebase storage bucket. */
  storageBucket: string;

  /** Firebase messaging sender ID. */
  messagingSenderId: string;
}

Create Default Function

The last step to scaffolding the project is to create what I commonly refer to as the “entry” function. This is the function that is invoked when a schematic is executed. If you recall, we specified the path to the src/index.js file in the collection.json file.

Add the following default function to the src/ng-add/index.ts file:



export default function(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

Build and Run

While our schematic is just an empty shell at the moment, let’s build and run the schematic to verify that what we have created so far works:

$ yarn build
$ schematics .:ng-add

Sandbox Testing

While a schematic should include a unit testing suite to provide the necessary coverage in order to validate the quality of the schematic, I like to use a sandbox testing approach to visually diff the changes made to my Angular application as a result of executing a schematic.

I learned this technique from a colleague, and very smart engineer, Kevin Schuchard. Check out his post on using a sandbox to develop an Angular Schematic.

First, create a new Angular application using the ng new command. I’ll provide the name “sandbox” for the application:

$ ng new sandbox

To avoid having an embedded git repository for our sandbox application remove the .git directory within the sandbox directory. Then, let’s create a new commit with all of the changes to our project.

$ rm -rf sandbox/.git
$ git add -A
$ git commit -m "Initial commit"

Then, let’s define some scripts to link, clean and test our schematic within the context of the Angular sandbox application using the Angular CLI:

{
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "clean": "git checkout HEAD -- sandbox && git clean -f -d sandbox",
    "link:schematic": "yarn link && cd sandbox && yarn link \"angular-fire-schematics\"",
    "sandbox:ng-add": "cd sandbox && ng g angular-fire-schematics:ng-add",
    "test": "yarn clean && yarn sandbox:ng-add && yarn test:sandbox",
    "test:unit": "yarn build && jasmine src/**/*_spec.js",
    "test:sandbox": "cd sandbox && yarn lint && yarn test && yarn build"
  }
}

Finally, link the angular-fire-schematics package into the sandbox by executing:

$ yarn link:schematic

We’re now ready to build and execute our schematic using the Angular CLI in our sandbox application.

Utilities

We’ll be using many utility functions provided by the @angular/schematics and @angular/cdk modules. Most of the functions are located within a utility directory. These utilities will enable us accomplish:

  • updating the dependencies in a project’s package.json file,
  • installing dependencies,
  • determining the workspace configuration of an Angular project,
  • updating Angular modules,
  • and more.

Let’s install both of the modules using either npm or yarn:

$ yarn add @angular/schematics @angular/cdk
$ npm install @angular/schematics @angular/cdk

I’ve also brought in several utility functions for this project. These utility functions are collected from a variety of sources, including the Angular CLI schematics, the CDK schematics, the Angular Material schematics, and more.

Copy the following files from the angular-fire-schematics src/util directory: npmjs.ts, project-configurations.ts, project-environment-file.ts and version-agnostic-typescript.ts.

Chain Rules

Our first task for building an ng-add schematic that installs and adds AngularFire to an Angular project is to add the necessary dependencies to the package.json file.

Update the default function in the src/index.ts file to use the chain() function to chain together multiple rules:

export default function(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return chain([
      addPackageJsonDependencies(),
      installDependencies(),
      setupProject(options)
    ])(tree, _context);
  };
}

Then, declare the addPackageJsonDependencies(), installDependencies() and setupProject() functions:

function addPackageJsonDependencies(): Rule {}

function installDependencies(): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  }
}

function setupProject(options: Schema): Rule {
  console.log(options);
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  }
}

Add Dependencies

Next, we’ll implement the addPackageJsonDependencies() function in the src/index.ts file:

import { addPackageJsonDependency, NodeDependency, NodeDependencyType } from '@schematics/angular/utility/dependencies';
import { getLatestNodeVersion, NpmRegistryPackage } from '../util/npmjs';

function addPackageJsonDependencies(): Rule {
  return (tree: Tree, _context: SchematicContext): Observable<Tree> => {
    return of('firebase', '@angular/fire').pipe(
      concatMap(name => getLatestNodeVersion(name)),
      map((npmRegistryPackage: NpmRegistryPackage) => {
        const nodeDependency: NodeDependency = {
          type: NodeDependencyType.Default,
          name: npmRegistryPackage.name,
          version: npmRegistryPackage.version,
          overwrite: false
        };
        addPackageJsonDependency(tree, nodeDependency);
        _context.logger.info('✅️ Added dependency');
        return tree;
      })
    );
  };
}

Let’s review:

  • First, the addPackageJsonDependencies() is a factory function that returns a Rule, which accepts the Tree and SchematicContext objects.
  • We create a new Observable using the of() function that emits two notifications that are strings: ‘firebase’ and ‘@angular/fire’.
  • I’m using a utility function getLatestNodeVersion() that returns an Observable of the NpmRegistryPackage for each of these dependencies from the http://registry.npmjs.org API.
  • We create a new NodeDependency object using the information provided by the API and then invoke the addPackageJsonDependency() function. These types and the function are imported from the @schematics/angular module and are located in the utility/dependencies directory.
  • The addPackageJsonDependency() function does the heavy lifting of adding the dependency to the package.json file.
  • Finally, we use the SchematicContext to log out an info statement indicating that the dependency was added successfully.

Then, run the build and test scripts:

$ yarn build
$ yarn test

Take a look at the diff for the package.json file:

$ git diff sandbox/package.json

Note that both the @angular/fire and firebase dependencies have been added to the sandbox application’s package.json file.

ng-add schematic add package dependencies

Install Dependencies

With our application’s package.json file updated the next step is to install the new dependencies.

Implement the installDependencies() as follows:

import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';

function installDependencies(): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    _context.addTask(new NodePackageInstallTask());
    _context.logger.debug('✅️ Dependencies installed');
    return tree;
  };
}

Thankfully this is very easy to accomplish using the NodePackageInstallTask class that is exported from the @angular-devkit/schematics/tasks module.

Let’s build and run the schematic again and we should observe that our new dependencies are installed after updating the package.json file:

$ yarn build
$ yarn test

ng-add schematic add package dependencies

Setup Schematic

The ng-add schematic adds the necessary dependencies and installs them. The next step is to create a schematic that performs the necessary setup and configuration for AngularFire.

Open the src/collection.json file and define a new schematic entitled ng-add-setup-project. The complete file should now look like:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Adds Angular Firebase to the application without affecting any templates",
      "factory": "./ng-add/index",
      "schema": "./ng-add/schema.json",
      "aliases": ["install"]
    },
    "ng-add-setup-project": {
      "description": "Sets up the specified project after the ng-add dependencies have been installed.",
      "private": true,
      "factory": "./ng-add/setup-project",
      "schema": "./ng-add/schema.json"
    }
  }
}

Next, create the src/ng-add/setup-project.ts file:

$ touch src/ng-add/setup-project.ts

To get started, we’ll stub out the default function:

import {
  chain,
  Rule,
  SchematicContext,
  Tree
  } from '@angular-devkit/schematics';
import { Schema } from './schema';

export default function(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return chain([
      addEnvironmentConfig(options),
      importEnvironemntIntoRootModule(options),
      addAngularFireModule(options)
    ])(tree, _context);
  };
}

function addEnvironmentConfig(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

function addAngularFireModule(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

function importEnvironemntIntoRootModule(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree;
  };
}

The plan is to:

  1. Add the configuration information to the environment.ts file.
  2. Import the environment.ts file into the app.module.ts file.
  3. Add the AngularFireModule.initializeApp() method to the imports array in the @NgModule() decorator for the AppModule class.

This seems pretty straight forward.

RunSchematicTask

Before we continue working on the setup we need to run the newly created ng-add-setup-project schematic from the ng-add schematic. In order to do this we’ll use the RunSchematicTask class.

Let’s finish implementing the setupProject() function in the src/ng-add/index.ts file:

function setupProject(options: Schema): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const installTaskId = _context.addTask(new NodePackageInstallTask());
    _context.addTask(new RunSchematicTask('ng-add-setup-project', options), [
      installTaskId
    ]);
    return tree;
  };
}

To review:

  • Before we can execute the task, we need to wait until the previous task to install the node package dependencies has completed. To do this, we first need to get the installTaskId that our new task is dependent upon.
  • Next we invoke the addTask() method on the SchematicContext class specifying the new RunSchematicTask followed by the array of dependent task identifiers.
  • Note that when running another schematic we specify the schematic name along with the options for the schematic. We’ll pass along the options that were specified by the user for the ng-add schematic.

Update environment.ts

Open the src/setup-project.ts file and implement the addEnvironmentConfig() function:

function addEnvironmentConfig(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const workspace = getWorkspace(tree);
    const project = getProjectFromWorkspace(workspace, options.project);
    const envPath = getProjectEnvironmentFile(project);

    // verify environment.ts file exists
    if (!envPath) {
      return context.logger.warn(
        `❌ Could not find environment file: "${envPath}". Skipping firebase configuration.`
      );
    }

    // firebase config to add to environment.ts file
    const insertion =
      ',\n' +
      `  firebase: {\n` +
      `    apiKey: '${options.apiKey}',\n` +
      `    authDomain: '${options.authDomain}',\n` +
      `    databaseURL: '${options.databaseURL}',\n` +
      `    projectId: '${options.projectId}',\n` +
      `    storageBucket: '${options.storageBucket}',\n` +
      `    messagingSenderId: '${options.messagingSenderId}',\n` +
      `  }`;
    const sourceFile = readIntoSourceFile(tree, envPath);

    // verify firebase config does not already exist
    const sourceFileText = sourceFile.getText();
    if (sourceFileText.includes(insertion)) {
      return;
    }

    // get the array of top-level Node objects in the AST from the SourceFile
    const nodes = getSourceNodes(sourceFile as any);
    const start = nodes.find(
      node => node.kind === ts.SyntaxKind.OpenBraceToken
    )!;
    const end = nodes.find(
      node => node.kind === ts.SyntaxKind.CloseBraceToken,
      start.end
    )!;

    const recorder = tree.beginUpdate(envPath);
    recorder.insertLeft(end.pos, insertion);
    tree.commitUpdate(recorder);

    context.logger.info('✅️ Environment configuration');
    return tree;
  };
}

This is pretty long!

Let’s dive in:

  • We’ll use a few utility functions provided by the @schematics/angular module, located within the utility directory.
  • First we get the WorkspaceSchema using the getWorkspace() function. This contains the metadata for the Angular workspace (introduced in Angular version 6).
  • Next we get the WorkspaceProject using the getProjectFromWorkspace() function. This contains the metadata for the Angular project (either lib or app), including: the root directory, the default component prefix, etc.
  • Then we get the path to the project’s environment.ts file. We’ll need to know this in order to update the file.
  • Next we build the insertion string. This contains the firebase configuration data that the user entered.
  • The readIntoSourceFile() function is important, and we’ll cover it in detail next. It returns the top-level abstract syntax tree (AST) node for a TypeScript file.
  • We also want to verify that the firebase config does not already exist in the environment.ts file. We’ll simply use the getText() method and then determine if the insertion text already exists. This is a bit brittle and could use some hardening to ensure that a Firebase configuration does not already exist.
  • Using the getSourceNodes() utility function we can retrieve an array of the Node objects within the SourceFile AST.
  • We’ll use the Array.prototype.find() method to find the Node that is an OpenBraceToken. Remember that the environment.ts file contains a single environment constant object that is exported. We are finding the node that is the opening brace ( { ) that starts the environment configuration object.
  • We’ll also find the end Node that is the closing brace ( } ).
  • Using the tree.beginUpdate() method we can start recording changes to the specified file in the tree.
  • We’ll insert the insertion text starting at the end of the environment object.
  • Finally, we log out some information stating that the environment configuration step has completed successfully.

What is an AST?

Before we go any further, let’s quickly review what an abstract syntax tree (AST) is.

According to Wikipedia:

In computer science, an abstract syntax tree (AST), or just syntax tree, is a tree representation of the abstract syntactic structure of source code written in a programming language. Each node of the tree denotes a construct occurring in the source code.

As you might guess TypeScript provides a declaration file located at lib/typescript.d.ts that includes all of the type definitions for a TypeScript abstract syntax tree.

Why is this important?

Using an AST we can modify TypeScript source files using a programmatic interface that is reliable.

Accessing the SourceFile

A SourceFile is the top-level Node for a TypeScript AST. This is the starting point for working with the AST. We’ll use the createSourceFile() method provided by TypeScript to access the SourceFile:

function readIntoSourceFile(host: Tree, fileName: string): SourceFile {
  const buffer = host.read(fileName);
  if (buffer === null) {
    throw new SchematicsException(`File ${fileName} does not exist.`);
  }

  return ts.createSourceFile(
    fileName,
    buffer.toString('utf-8'),
    ts.ScriptTarget.Latest,
    true
  );
}

First, we use the read() method on the Tree to get the file Buffer. Then, we invoke the createSourceFile() method, providing the file path and the contents of the TypeScript file.

Import environment

Back to the task of creating an ng-add schematic for AngularFire, the next step for our schematic is to implement the importEnvironemntIntoRootModule() function. This function is responsible for importing the environment constant object into the root AppModule using the standard es6 import syntax.

As we’ve done before, we’ll use a few utility functions that are provided by the @schematics/angular module.

Open the src/setup-project.ts and implement the importEnvironemntIntoRootModule() function:

function importEnvironemntIntoRootModule(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const IMPORT_IDENTIFIER = 'environment';
    const workspace = getWorkspace(tree);
    const project = getProjectFromWorkspace(workspace, options.project);
    const appModulePath = getAppModulePath(tree, getProjectMainFile(project));
    const envPath = getProjectEnvironmentFile(project);
    const sourceFile = readIntoSourceFile(tree, appModulePath);

    if (isImported(sourceFile as any, IMPORT_IDENTIFIER, envPath)) {
      context.logger.info(
        '✅️ The environment is already imported in the root module'
      );
      return tree;
    }

    const change = insertImport(
      sourceFile as any,
      appModulePath,
      IMPORT_IDENTIFIER,
      envPath.replace(/\.ts$/, '')
    ) as InsertChange;

    const recorder = tree.beginUpdate(appModulePath);
    recorder.insertLeft(change.pos, change.toAdd);
    tree.commitUpdate(recorder);

    context.logger.info('✅️ Import environment into root module');
    return tree;
  };
}

Let’s dive in:

  • We’ll use some familiar functions to get information on the workspace and project that we are executing the schematic within.
  • The getAppModulePath() returns the path string to the app.module.ts file within the Angular project (either lib or app).
  • Using the information we have on the environment file and the root application module we are ready to insert the es6 import statement into the app.module.ts file.
  • We first verify that the environment identifier is not already imported into the module. If it is, then we are done here.
  • Otherwise, we need to insert the import. We’ll use the insertImport() function exported in the @angular/schematics module to create a new InsertChange object.
  • Using the change object we start and commit an update to the app module file using the UpdateRecorder.

Import Module

The final step is to update the @NgModule() decorator for the AppModule to import the Firebase module.

Open src/setup-project.ts and implement the addAngularFireModule() function:

function addAngularFireModule(options: Schema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const MODULE_NAME = 'AngularFireModule.initializeApp(environment.firebase)';
    const workspace = getWorkspace(tree);
    const project = getProjectFromWorkspace(workspace, options.project);
    const appModulePath = getAppModulePath(tree, getProjectMainFile(project));

    // verify module has not already been imported
    if (hasNgModuleImport(tree, appModulePath, MODULE_NAME)) {
      return console.warn(
        red(
          `Could not import "${bold(MODULE_NAME)}" because "${bold(
            MODULE_NAME
          )}" is already imported.`
        )
      );
    }

    // add NgModule to root NgModule imports
    addModuleImportToRootModule(tree, MODULE_NAME, '@angular/fire', project);

    context.logger.info('✅️ Import AngularFireModule into root module');
    return tree;
  };
}

Let’s review:

  • Again, you’ll note that we are using several utility functions to access the metadata of the workspace and project, and then using this, we get the path to the app.module.ts file.
  • First we verify that the module has not alredy been imported using the hasNgModuleImport() function. This one I sourced from the @angular/cdk module, located in the schematics/utils/ast directory.
  • Then we invoke the addModuleImportToRootModule() function to add the Firebase module import to the @NgModule() decorator’s import array.

Conclusion

I hope that this post is helpful for those with an interest in learning to build schematics either for their own projects or organization, or for contributing to the Angular community.

I’ve presented on building Angular schematics at several meetups and one of the common questions I get asked is:

“How did you know where to find those utility functions?”

By studying the Angular CLI, Angular Material and Angular CDK schematics I learned about the many utility functions that are used by these teams to build schematics. I suggest you check out the source code to these schematics to help you learn how to build schematics and to discover the many utility functions that they provide and use.

Brian Love

Hi, I'm Brian. I am interested in TypeScript, Angular and Node.js. I'm married to my best friend Bonnie, I live in Denver and I ski (a lot).