⚠️ Warning: Not part of the official API

The helper functions I’m about to show you are not documented, not officially supported, and they may change without notice in future releases.

Alan Agius, a member of the Angular CLI core team, responded to a GitHub issue (#15335) that asked for a public schematics API reference:

[…] those utils are not considered as part of the public API and might break without warning in any release.

There is work planned to expose some of these utilities through a public API, but that effort is still in its early stages. I’ll do my best to keep this article current as things evolve.

The utility functions described below are based on Angular CLI version 11.0.0.

If any of these functions break in a future release, you can inspect the source code for the utility functions and adjust your code accordingly.

? Code examples and playground on GitHub

To help you follow along with the examples in this article, I’ve created a playground repository on GitHub.

Clone the repository and check the README.md file to get started with the playground.

Setting up an Angular schematics example project

Before we dive in, we need an existing project to experiment with.

You can use a schematics project you already have on hand, or create a brand new, empty one:

npx @angular-devkit/schematics-cli blank --name=playground

If you’re new to the fundamentals of writing schematics, I recommend starting with the official Angular documentation, the blog post „Total Guide To Custom Angular schematics” by Tomas Trajan, and the series „Angular Schematics from 0 to publishing your own library” by Natalia Venditto.

Once you’ve generated the blank project, you should see a file called src/playground/index.ts.

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

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    console.log('schematic works');
    return tree;
  };
}

This file serves as the starting point for all the examples that follow.

Make sure you can run the blank schematic from the console before proceeding:

npx @angular-devkit/schematics-cli .:playground

Or, if you’ve installed the schematics CLI globally with npm i @angular-devkit/schematics-cli:

schematics .:playground

The trailing . indicates the current directory where your schematics project resides.

Check out the basic example in the playground repository on GitHub

Core concepts and types

If you haven’t worked with schematics before, let’s quickly cover the essential building blocks:

  • A Tree is the virtual and structured representation of every file in the workspace the schematic runs against.
  • A Rule receives a Tree and a SchematicContext. The Rule is responsible for modifying the Tree and then returning the updated Tree.
  • The SchematicContext carries the details and services the schematic needs to carry out its actions.

Adding the helpers from @schematics/angular

Another step we need to take is installing the @schematics/angular package. This package contains all the utility functions we’ll use in the upcoming examples.

This same package is the one the Angular CLI uses internally when you run commands like ng generate or ng new.

npm i --save @schematics/angular

Working with package.json: Get, Add and Remove (dev-, peer-) dependencies

One of the most common tasks when authoring a schematic is adding a dependency to the project’s package.json.

You could write your own functions to parse and modify that JSON file, and that would certainly work.

But why reinvent the wheel when the solution already exists?

The functions exported from @schematics/angular/utility/dependencies are designed specifically for managing dependency entries. The addPackageJsonDependency() function lets you insert a NodeDependency object into the package.json. The type field must be one of the values from the NodeDependencyType enum.

These values correspond to the different sections you can have in a package.json:

  • dependencies,
  • devDependencies,
  • peerDependencies and
  • optionalDependencies.

The first argument for this utility is the Tree that holds all the files. The function doesn’t simply append the new entry to the end of the section; it inserts it in the correct position so that all keys remain sorted alphabetically.

If you need to fetch the existing configuration for a particular dependency, you can use the getPackageJsonDependency() function. It returns a NodeDependency object.

A nice benefit of this function is that you don’t have to remember which section of the package.json holds the dependency. The function searches all four sections — dependencies, devDependencies, peerDependencies and optionalDependencies — to find it.

The third utility in this group is removePackageJsonDependency(). Like the getter function, it takes a Tree and the package name, then removes the dependency from the package.json if it exists.

By default, these functions look for the package.json file in the root of the tree. If you need to work with a different package.json, you can pass a third argument with the specific file path.

Finally, we probably don’t want to force our users to manually run npm install after the schematic has made its changes. To handle this automatically, we can register a new NodePackageInstallTask by calling the addTask method on our context.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import {
  NodeDependency,
  NodeDependencyType,
  getPackageJsonDependency,
  addPackageJsonDependency,
  removePackageJsonDependency,
} from '@schematics/angular/utility/dependencies';

export function playground(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const dep: NodeDependency = {
      type: NodeDependencyType.Dev,
      name: 'moment',
      version: '~2.27.0',
      overwrite: true,
    };

    addPackageJsonDependency(tree, dep);
    console.log(getPackageJsonDependency(tree, 'moment'))
    // { type: 'devDependencies', name: 'moment', version: '~2.27.0' }

    removePackageJsonDependency(tree, 'protractor');
    console.log(getPackageJsonDependency(tree, 'protractor'))
    // null

    context.addTask(new NodePackageInstallTask(), []);

    return tree;
  };
}

Keep in mind that in order to actually see the NodePackageInstallTask execute, you need to turn off the schematics debug mode, which is enabled by default during local development:

schematics .:playground --debug=false

Inserting content at a specific location

It’s often necessary to modify the contents of a file at a specific position. Regardless of the file type, you can leverage the InsertChange class for this purpose. This class produces a change object that contains both the content to be added and the exact position where it should be inserted.

In the example below, we’ll create a new file called my-file.extension inside the virtual tree, with the content const a = 'foo';. We start by creating an instance of InsertChange, passing the file path, the insertion position, and the content we want to add.

Next, we initiate the update process by calling the beginUpdate() method on the tree. This call returns an object of type UpdateRecorder. With that recorder, we can use the insertLeft() method, supplying the position and the content (toAdd) from the InsertChange object. At this point, the change is only recorded; it hasn’t been applied to the file yet.

To actually apply the recorded changes to the file, we need to call commitUpdate() on the tree, passing the exportRecorder. Once this is done, you can call tree.get(filePath) and log the content to verify that the change has taken effect.

If you need to remove a file from the virtual tree, the delete() method on the tree, with the file path, is what you need.

Here’s an example of how this works in practice:

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics/';
import { InsertChange } from '@schematics/angular/utility/change';

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const filePath = 'my-file.extension';
    tree.create(filePath, `const a = 'foo';`);

    // insert a new change
    const insertChange = new InsertChange(filePath, 16, '\nconst b = \'bar\';');
    const exportRecorder = tree.beginUpdate(filePath);
    exportRecorder.insertLeft(insertChange.pos, insertChange.toAdd);
    tree.commitUpdate(exportRecorder);
    console.log(tree.get(filePath)?.content.toString())
    // const a = 'foo';
    // const b = 'bar';

    tree.delete(filePath); // cleanup (if not running schematic in debug mode)
    return tree;
  };
}

Calculating the relative path to the workspace root

Sometimes you’ll need to determine the relative path from a given location back to the root of your project — for instance, when you’re injecting it into a template that will be applied somewhere in the app. To get the correct relative import path string, you can use the relativePathToWorkspaceRoot() helper.

import {
  Rule,
  SchematicContext,
  Tree,
  url,
  apply,
  template,
  mergeWith
} from '@angular-devkit/schematics/';
import { relativePathToWorkspaceRoot } from '@schematics/angular/utility/paths';

export function playground(_options: any): Rule {
  return (_tree: Tree, _context: SchematicContext) => {
    const nonRootPathDefinition = 'foo/bar/'; // "./foo/bar" | "foo/bar/" work also
    const rootPathDefinition = ''; // "." | "./" work also
    console.log(relativePathToWorkspaceRoot(nonRootPathDefinition));
    // "../.."
    console.log(relativePathToWorkspaceRoot(rootPathDefinition));
    // "."

    const sourceTemplates = url('./files');
    return mergeWith(
      apply(
        sourceTemplates, [
          template({
            relativePathToWorkspaceRoot: relativePathToWorkspaceRoot(nonRootPathDefinition),
          }),
        ]
      )
    );
  };
}

For example, if you have a JSON file template inside a files directory and you want to insert the path into it, you can call the helper function directly within the template:

{
  "foo": "<%= relativePathToWorkspaceRoot %>/my-file-ref.json"
}

For a deeper look into how to use and apply templates in your own schematics, check out the blog post by Tomas Trajan: „Total Guide To Custom Angular schematics” and the article series „Angular Schematics from 0 to publishing your own library” by Natalia Venditto.

Handle TypeScript imports

The earlier method of inserting content directly into a file works only when you know exactly where the insertion should occur. If the user has reformatted the file, pinpointing the correct location becomes unreliable.

Frequently, schematics need to alter TypeScript files by injecting code. Fortunately, a set of utilities exists to handle such modifications more robustly.

Consider a scenario where you want a schematic to bring the class Bar into a file from bar.ts. A naive approach would append the entire import statement. However, this can fail if the target file already has an import from bar.ts, potentially creating duplicate import lines for the same module.

To avoid this, use the insertImport() helper. This function intelligently adds a new import or updates an existing one. It requires the source file, the import name, and the module path. An optional last parameter, when set to true, designates the import as a default import.

import * as ts from 'typescript';
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics/';
import { insertImport } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const filePath = 'some-file.ts';
    const fileContent = `import { Foo } from 'foo';
const bar = 'bar;
`;
    tree.create(filePath, fileContent);
    const source = ts.createSourceFile(
      filePath,
      fileContent,
      ts.ScriptTarget.Latest,
      true
    );
    const updateRecorder = tree.beginUpdate(filePath);
    const change = insertImport(source, filePath, 'Bar', './bar', true);
    if (change instanceof InsertChange) {
      updateRecorder.insertRight(change.pos, change.toAdd);
    }
    tree.commitUpdate(updateRecorder);
    console.log(tree.get(filePath)?.content.toString())
    return tree;
  };
}

In this instance, the code import Bar from './bar'; is placed right before the constant. Because it was flagged as a default import, the class name is not enclosed in braces.

Modify NgModule

While adding imports is a crucial step, it's often only part of the puzzle. Typical tasks involve importing a component and then adding it to the NgModule declarations array, or inserting a module into the imports section. The utilities for these operations are built on top of insertImport(), ensuring they manage existing imports and update lists correctly.

Add a declaration to a module

First, let's look at adding a component to an NgModule declarations array. Suppose your schematic introduces a new DashboardComponent to a project. Instead of manually crafting an import and then finding the exact spot in the declarations array, you can use the addDeclarationToModule() function from @schematics/angular/utility/ast-utils.

Here's how it works: start by creating an AppModule from a string using ts.createSourceFile(). Then, set up an updateRecorder as shown previously. Next, invoke addDeclarationToModule(), passing the source file, the module file path, the component name to import, and the relative path to the component. The function returns an array of Change objects containing the insertion points and content. Iterate through these changes; for any of type InsertChange, use the recorder to insert the content at the specified position.

import * as ts from 'typescript';
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics/';
import { addDeclarationToModule } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const modulePath = 'app.module.ts';
    const moduleContent = `import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
`;
    tree.create(modulePath, moduleContent);

    const source = ts.createSourceFile(
      modulePath,
      moduleContent,
      ts.ScriptTarget.Latest,
      true
    );
    const updateRecorder = tree.beginUpdate(modulePath);
    const changes = addDeclarationToModule(
      source,
      modulePath,
      'DashboardComponent',
      './dashboard.component'
    ) as InsertChange[];
    for (const change of changes) {
      if (change instanceof InsertChange) {
        updateRecorder.insertLeft(change.pos, change.toAdd);
      }
    }
    tree.commitUpdate(updateRecorder);
    console.log(tree.get(modulePath)?.content.toString())

    return tree;
  };
}

After running this schematic, the log shows that the appropriate import line was added to the file.

/* ... */
import { DashboardComponent } from './dashboard.component';

@NgModule({
  declarations: [
    AppComponent,
    DashboardComponent
  ],
  /* ... */
})
export class AppModule { }

NgModule: manage imports, exports, providers, and bootstrap

The toolkit also includes analogous functions for other metadata arrays. Use addExportToModule() to re-export an item and addImportToModule() to add a new module to the imports section. Similarly, addProviderToModule() and addBootstrapToModule() modify their respective arrays. These helpers automatically handle import creation and extension, check for duplicates in the metadata, and handle other necessary details.

/* ... */
import {
  addImportToModule,
  addExportToModule,
  addProviderToModule,
  addBootstrapToModule
} from '@schematics/angular/utility/ast-utils';
/* ... */

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    /* ... */
    const exportChanges = addExportToModule(
      source,
      modulePath,
      'FooModule',
      './foo.module'
    ) as InsertChange[];
    const importChanges = addImportToModule(
      source,
      modulePath,
      'BarModule',
      './bar.module'
    ) as InsertChange[];
    const providerChanges = addProviderToModule(
      source,
      modulePath,
      'MyProvider',
      './my-provider.ts'
    ) as InsertChange[];
    const bootstrapChanges = addBootstrapToModule(
      source,
      modulePath,
      'MyComponent',
      './my.component.ts'
    ) as  InsertChange[];
    /* ... */
    console.log(tree.get(modulePath)?.content.toString())
    return tree;
  };
}

After applying these, the resulting module will look like the following.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { FooModule } from './foo.module';
import { BarModule } from './bar.module';
import { MyProvider } from './my-provider.ts';
import { MyComponent } from './my.component.ts';
import { BazComponent } from './baz.component.ts';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    BarModule
  ],
  providers: [MyProvider],
  bootstrap: [MyComponent],
  exports: [FooModule]
})
export class AppModule { }

Add route declarations

Another common need is inserting a route definition into a module that uses RouterModule.forRoot() or .forChild() with a routes array. The addRouteDeclarationToModule() helper is designed for this. It returns a Change object that must be processed as an InsertChange.

import * as ts from 'typescript';
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics/';
import { addRouteDeclarationToModule } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    const modulePath = 'my-routing.module.ts';
    const moduleContent = `import { NgModule } from '@angular/core';

    const myRoutes = [
      { path: 'foo', component: FooComponent }
    ];

    @NgModule({
      imports: [
        RouterModule.forChild(myRoutes)
      ],
    })
    export class MyRoutingModule { }
`;
    tree.create(modulePath, moduleContent);

    const source = ts.createSourceFile(
      modulePath,
      moduleContent,
      ts.ScriptTarget.Latest,
      true
    );
    const updateRecorder = tree.beginUpdate(modulePath);
    const change = addRouteDeclarationToModule(
      source,
      './src/app',
      `{ path: 'bar', component: BarComponent }`
    ) as InsertChange;
    updateRecorder.insertLeft(change.pos, change.toAdd);
    tree.commitUpdate(updateRecorder);
    console.log(tree.get(modulePath)?.content.toString())

    return tree;
  };
}

The code above adds the { path: 'bar', component: BarComponent } route definition into the myRoutes array. It does so by locating the variable used in the forRoot() or forChild() call.

Access the Angular workspace configuration

Every Angular application is contained within a workspace, which is defined by the angular.json file. To retrieve the path to this configuration file or the configuration object itself, pass the current Tree to the getWorkspacePath() and getWorkspace() functions.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { getWorkspacePath, getWorkspace } from '@schematics/angular/utility/config';

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    // returns the path to the Angular configuration file
    // ('/angular.json' or probably `.angular.json` for older Angular projects)
    console.log(getWorkspacePath(tree));

    // returns the whole configuration object from the 'angular.json' file
    console.log(JSON.stringify(getWorkspace(tree), null, 2));
  };
}

For local testing, run the schematic from the root of an Angular application. Navigate to an existing Angular project or create a new one for this purpose. Then execute the schematic by providing the relative path to the src/collection.json file, followed by a colon (:) and the schematic name.

ng new some-test-project --routing  # create a new test project
cd some-test-project      # be sure to be in the root of the angular project
# assume the schematics project itself is located relatively to the angular project in '../playground'
schematics ../playground/src/collection.json:playground # execute the 'playground' schematic

Find the default path for an app in the workspace

An Angular workspace can host multiple applications and libraries. The createDefaultPath() helper finds the standard source path for a given project. Provide the Tree and the project's name to get back the path.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { createDefaultPath } from '@schematics/angular/utility/workspace';

export function playground(_options: any): Rule {
  return async (tree: Tree, _context: SchematicContext) => {
    const defaultPath = await createDefaultPath(tree, 'my-lib');
    console.log(defaultPath); // '/projects/my-lib/src/lib'
  };
}

To see this in action, create a new library, for example, my-lib, inside your testing Angular app, and then run the schematic.

ng g lib my-lib  # create a new library inside the Angular workspace
# assume the schematics project itself is located relatively to the angular project in '../playground'
schematics ../playground/src/collection.json:playground # execute the 'playground' schematic

Executing Schematics from Within Other Schematics

As you build out your schematic collection, you might encounter a scenario where one schematic needs to trigger another one. For instance, you could have a schematic dedicated to generating a specific component, while also having an ng add or ng new schematic that sets up a project's foundation and, as part of that process, creates an example component. In these situations, being able to chain multiple schematics together becomes essential.

Triggering Local Schematics with the RunSchematicTask Class

The RunSchematicTask class provides a straightforward way to launch another schematic from your current one. Consider a collection file structured like this:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Demo that calls the 'playground' schematic inside",
      "factory": "./ng-add/index#ngAdd"
    },
    "playground": {
      "description": "An example schematic.",
      "factory": "./playground/index#playground"
    }
  }
}

Assuming the factory for ng-add is defined in src/ng-add/index.ts, you can instantiate a new RunSchematicTask from within that schematic. This task requires two arguments: the name of the schematic you wish to execute and the name of the project from the Angular workspace. To ensure the task is actually processed, it must be added to the context object.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { RunSchematicTask } from '@angular-devkit/schematics/tasks';

export function ngAdd(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    context.addTask(
      new RunSchematicTask('playground', { project: 'test-workspace' })
    );
    return tree;
  };
}

To verify this works, you can set up your playground schematic (in src/playground/index.ts) to log a message when it's called:

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

export function playground(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    console.log('schematic \'playground\' called');
    return tree;
  };
}

Running the command schematics ../playground/src/collection.json:ng-add --debug=false from within your example Angular project will confirm that the ng-add schematic successfully invoked the playground schematic.

This pattern allows you to design small, focused, and reusable schematics that can function independently or be orchestrated by a higher-level schematic designed to combine them with specific arguments.

Running Schematics with the schematic() and externalSchematic() Functions

While running our own schematics is useful, the need often arises to integrate schematics created by others into our own workflow. Since users appreciate a smooth experience, we wouldn't want to burden them with manually running external steps before our schematic executes.

Picture a large organization with numerous, diverse Angular projects managed by different teams. This company has established a standard UI library, but given the varied nature of the applications and teams (a monorepo isn't an option), they also share common infrastructure like a Single Sign-On (SSO) system. Additionally, there are general design principles, such as a consistent header and footer, applied across all applications.

A common, yet problematic, approach is to create a reference implementation that teams clone, copy, and modify. This workflow, however, presents several challenges:

  • The reference project must be continuously updated and maintained.
  • Teams need to manually clean up their copy, removing features they don't need.
  • It requires constant communication to ensure all teams have synced with the latest reference version.

It is far more efficient to leverage schematics for both the initial integration and the ongoing upgrade process. You could design an ng new schematic to scaffold the entire project structure. Rather than starting from scratch, this schematic could be used to combine various setup steps, such as:

  • Adding a standard set of company defaults to an existing project via ng add.
  • Including the corporate UI library (always).
  • Optionally integrating Single Sign-On (SSO).
  • Optionally generating a Header Component.
  • Optionally generating a Footer Component.
  • Creating a fully-configured project with the `ng new` flowchart:
    • Generating the base Angular application by using the Angular CLI's own schematic (via the externalSchematic function).
    • Then running your specific ng add schematic to add company customizations.

We are already familiar with most of these steps. The remaining puzzle is figuring out how to execute external schematics, which is where the externalSchematic function comes into play.

Before diving in, let's ensure our collection file is correctly set up:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Call other schematics from the same or other packages",
      "factory": "./ng-add/index#playground"
    },
    "ng-new": {
      "description": "Execute `ng new` with predefined options and run other stuff",
      "factory": "./ng-new/index#playground"
    }
  }
}

By using the special schematic names ng-add and ng-new, you enable execution via the standard ng add/ng new commands. A schematic named ng-update is also recognized and will be invoked at the end of a project update using the ng update CLI command.

With our schema defined, we can proceed with the implementation. To run an external schematic, it needs to be within the project's scope. But when creating a brand-new project with the ng new schematic, there are no node_modules installed in the target directory we're initializing. To handle this, we can use the spawn method from child_process, a globally available Node.js function. This method creates a new child process to run a command, which in our case is npm install @schematics/angular. To keep the logic sequential, we can wrap the spawn call in a Promise and use await. By listening to the close event, we can check the exit code (expecting 0) to confirm a successful installation. If the install succeeds, we resolve the Promise; otherwise, we can throw an error.

The final step is to chain all the necessary Rules. The first rule uses the externalSchematic() function to invoke Angular's own ng new schematic to establish the base application. We can pass default options, such as using SCSS, enabling support for legacy browsers, and activating strict mode. The ng new schematic also requires a specific schematic version to be identified; in this instance, we'll pin it to the Angular CLI version 11.0.0. The second rule in our chain is an invocation of our own ng add schematic, which is responsible for adding our company-specific components and UI library to the project.

We've discussed using the RunSchematicTask class, passed via the context object, to run a local schematic. This example, however, uses the schematic() function for a similar purpose. The reason for two different approaches isn't entirely clear; both implementations exist in the Angular CLI's source code.

import {
  Rule,
  SchematicContext,
  Tree,
  externalSchematic,
  schematic,
  chain
} from '@angular-devkit/schematics';
import {
  Schema as AngularNgNewSchema,
  PackageManager,
  Style
} from '@schematics/angular/ng-new/schema';
import { spawn } from 'child_process';

export function playground(options: AngularNgNewSchema): Rule {
  return async (_tree: Tree, _context: SchematicContext) => {
    const angularSchematicsPackage = '@schematics/angular';
    const ngNewOptions: AngularNgNewSchema = {
      version: '11.0.0',
      name: options.name,
      routing: true,
      strict: true,
      legacyBrowsers: true,
      style: Style.Scss,
      packageManager: PackageManager.Npm
    }
    await new Promise<boolean>((resolve) => {
      console.log('? Installing packages...');
      spawn('npm', ['install', angularSchematicsPackage])
        .on('close', (code: number) => {
          if (code === 0) {
            console.log('? Packages installed successfully ✅');
            resolve(true);
          } else {
            throw new Error(
              `❌ install Angular schematics from '${angularSchematicsPackage}' failed`
            );
          }
        });
    });
    return chain([
      externalSchematic(angularSchematicsPackage, 'ng-new', ngNewOptions),
      schematic('ng-add', {})
    ]);
  };
}

When you execute this ng new schematic from a location outside an existing Angular workspace, you'll observe that the Angular CLI's ng new schematic runs first with the predefined options. Following that, your ng add schematic gets executed.

schematics ./playground/src/collection.json:ng-new --debug=false
? Installing packages...
? Packages installed successfully ✅
? What name would you like to use for the new workspace and initial project? my-project
CREATE my-project/README.md (1027 bytes)
CREATE my-project/.editorconfig (274 bytes)
CREATE my-project/.gitignore (631 bytes)
CREATE my-project/angular.json (3812 bytes)
...
CREATE my-project/src/app/app.component.scss (0 bytes)
CREATE my-project/src/app/app.component.html (25757 bytes)
CREATE my-project/src/app/app.component.spec.ts (1069 bytes)
CREATE my-project/src/app/app.component.ts (215 bytes)
CREATE my-project/src/app/package.json (816 bytes)
CREATE my-project/e2e/protractor.conf.js (869 bytes)
CREATE my-project/e2e/tsconfig.json (294 bytes)
CREATE my-project/e2e/src/app.e2e-spec.ts (643 bytes)
CREATE my-project/e2e/src/app.po.ts (301 bytes)
 Installing packages...
 Packages installed successfully.
schematic works

After publishing your schematic, users can initiate this process by running:

npm i -g my-schematic-package-name # install the Schematic so it's available globally
ng new my-app --collection=my-schematic-package-name # Run the Angular CLI's `ng new` Schematic with the defined collection

Similarly, if you are already inside an Angular workspace, the ng add schematic can be called from your collection:

ng add my-schematic-package-name

Closing Thoughts

The utility functions described here are powerful and convenient tools for developing Angular CLI schematics. It's important to note that since they aren't officially documented, you should monitor the related documentation issue (#15335) and keep an eye on any changes to the code for potential alterations.

Reference Table

Function

Description

getPackageJsonDependency()

Fetches a package configuration from the package.json (dev-, peer-, optional-) dependencies section.

addPackageJsonDependency()

Adds an NPM package to the package.json as a (dev-, peer-, optional-) dependency.

removePackageJsonDependency()

Removes an NPM package from the package.json (dev-, peer-, optional-) dependencies section.

relativePathToWorkspaceRoot()

Provides the relative import path from a given file to the root of the Angular workspace.

insertImport()

Adds an import statement to an existing TypeScript file.

addDeclarationToModule()

Adds a declaration (e.g., Component or Directive) to the declarations array of an Angular module and generates the necessary import.

addImportToModule()

Imports an Angular Module and adds it to the imports array of another Angular module.

addExportToModule()

Imports an Angular Module and adds it to the exports array of another Angular module.

addProviderToModule()

Adds a service or provider to the providers array of an Angular module and handles its import.

addBootstrapToModule()

Adds a Component to the bootstrap array of an Angular module and handles its import.

addRouteDeclarationToModule()

Adds a route definition to the router configuration in an Angular routing module.

getWorkspacePath()

Returns the path to the Angular workspace configuration file (angular.json).

getWorkspace()

Retrieves the configuration object from the Angular workspace configuration file (angular.json).

createDefaultPath()

Retrieves the default application or library path for a specified project within an Angular workspace.

Class

Description

InsertChange

A class that creates a change object containing the content to be inserted and its position within the file.

NodePackageInstallTask

A task that runs npm install when added to the context via addTask().

RunSchematicTask

A task that executes another schematic after being added to the context via addTask().

Acknowledgments

A sincere thank you to Minko Gechev, Tomas Trajan, and Ferdinand Malcher for their valuable feedback and careful review of this article.