Skip to main content
Custom Schematics

Custom Schematics Overview

Estimated Time: 3 hours | Difficulty: Advanced | Prerequisites: Angular CLI, TypeScript
Schematics are code generators that can create, modify, or delete files in your project. They enable automation of repetitive tasks and enforcement of coding standards.
┌─────────────────────────────────────────────────────────────────────────┐
│                    Schematics Architecture                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   User Command                                                           │
│   ng generate my-schematic:component button                             │
│                    │                                                     │
│                    ▼                                                     │
│   ┌─────────────────────────────────────────────────────────────┐       │
│   │                    Schematic Collection                      │       │
│   │   collection.json - defines available schematics            │       │
│   └─────────────────────────────────────────────────────────────┘       │
│                    │                                                     │
│                    ▼                                                     │
│   ┌─────────────────────────────────────────────────────────────┐       │
│   │                    Schematic Factory                         │       │
│   │   src/component/index.ts - implements the schematic        │       │
│   └─────────────────────────────────────────────────────────────┘       │
│                    │                                                     │
│                    ▼                                                     │
│   ┌───────────────────────────────┐   ┌─────────────────────────┐       │
│   │         Virtual Tree          │   │       Schema.json        │       │
│   │   Represents file system     │   │   Defines options/inputs │       │
│   │   changes before applying    │   │                          │       │
│   └───────────────────────────────┘   └─────────────────────────┘       │
│                    │                                                     │
│                    ▼                                                     │
│   ┌─────────────────────────────────────────────────────────────┐       │
│   │                    File Operations                           │       │
│   │   • Create files from templates                             │       │
│   │   • Modify existing files (AST manipulation)                │       │
│   │   • Delete files                                            │       │
│   │   • Move/rename files                                       │       │
│   └─────────────────────────────────────────────────────────────┘       │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Setting Up Schematics Project

# Install schematics CLI
npm install -g @angular-devkit/schematics-cli

# Create new schematics project
schematics blank my-schematics

# Or use collection template
schematics schematic --name=my-schematics

cd my-schematics
npm install

Project Structure

my-schematics/
├── src/
│   ├── collection.json           # Collection metadata
│   ├── my-schematic/
│   │   ├── index.ts             # Factory function
│   │   ├── schema.json          # Options schema
│   │   ├── schema.d.ts          # TypeScript interface
│   │   └── files/               # Template files
│   │       └── __name@dasherize__/
│   │           └── __name@dasherize__.component.ts.template
├── package.json
└── tsconfig.json

Collection Configuration

// src/collection.json
{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "component": {
      "description": "Generate a component with our standards",
      "factory": "./component/index#component",
      "schema": "./component/schema.json",
      "aliases": ["c"]
    },
    "service": {
      "description": "Generate a service with our patterns",
      "factory": "./service/index#service",
      "schema": "./service/schema.json",
      "aliases": ["s"]
    },
    "ng-add": {
      "description": "Add this library to an Angular project",
      "factory": "./ng-add/index#ngAdd",
      "schema": "./ng-add/schema.json"
    }
  }
}

Creating a Component Schematic

Schema Definition

// src/component/schema.json
{
  "$schema": "http://json-schema.org/draft-07/schema",
  "$id": "ComponentSchema",
  "title": "Component Schema",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "The name of the component",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "What name would you like to use for the component?"
    },
    "path": {
      "type": "string",
      "description": "The path to create the component",
      "default": "src/app"
    },
    "project": {
      "type": "string",
      "description": "The name of the project"
    },
    "standalone": {
      "type": "boolean",
      "description": "Generate a standalone component",
      "default": true
    },
    "withTests": {
      "type": "boolean",
      "description": "Include spec file",
      "default": true,
      "x-prompt": "Would you like to generate a spec file?"
    },
    "withStory": {
      "type": "boolean",
      "description": "Include Storybook story file",
      "default": false
    },
    "style": {
      "type": "string",
      "description": "The style file extension",
      "enum": ["scss", "css", "less"],
      "default": "scss"
    }
  },
  "required": ["name"]
}
// src/component/schema.d.ts
export interface ComponentSchema {
  name: string;
  path?: string;
  project?: string;
  standalone?: boolean;
  withTests?: boolean;
  withStory?: boolean;
  style?: 'scss' | 'css' | 'less';
}

Factory Implementation

// src/component/index.ts
import {
  Rule,
  SchematicContext,
  Tree,
  apply,
  url,
  template,
  move,
  chain,
  mergeWith,
  MergeStrategy,
  filter,
  noop
} from '@angular-devkit/schematics';
import { strings, normalize } from '@angular-devkit/core';
import { ComponentSchema } from './schema';

export function component(options: ComponentSchema): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info(`Generating component: ${options.name}`);
    
    // Normalize options
    const normalizedPath = normalize(`${options.path}/${strings.dasherize(options.name)}`);
    
    // Apply templates
    const templateSource = apply(url('./files'), [
      // Filter out test files if not needed
      options.withTests ? noop() : filter(path => !path.endsWith('.spec.ts.template')),
      // Filter out story files if not needed
      options.withStory ? noop() : filter(path => !path.endsWith('.stories.ts.template')),
      // Apply template transformations
      template({
        ...strings,  // dasherize, classify, camelize, etc.
        ...options,
        name: options.name
      }),
      // Move to target directory
      move(normalizedPath)
    ]);
    
    return chain([
      mergeWith(templateSource, MergeStrategy.Overwrite),
      updateBarrelFile(normalizedPath, options.name)
    ])(tree, context);
  };
}

function updateBarrelFile(componentPath: string, name: string): Rule {
  return (tree: Tree) => {
    const indexPath = `${componentPath}/../index.ts`;
    const exportStatement = `export * from './${strings.dasherize(name)}/${strings.dasherize(name)}.component';\n`;
    
    if (tree.exists(indexPath)) {
      const content = tree.read(indexPath)?.toString('utf-8') ?? '';
      if (!content.includes(exportStatement)) {
        tree.overwrite(indexPath, content + exportStatement);
      }
    } else {
      tree.create(indexPath, exportStatement);
    }
    
    return tree;
  };
}

Template Files

// src/component/files/__name@dasherize__/__name@dasherize__.component.ts.template
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
<% if (standalone) { %>import { CommonModule } from '@angular/common';<% } %>

@Component({
  selector: 'app-<%= dasherize(name) %>',
  <% if (standalone) { %>standalone: true,
  imports: [CommonModule],<% } %>
  templateUrl: './<%= dasherize(name) %>.component.html',
  styleUrl: './<%= dasherize(name) %>.component.<%= style %>',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class <%= classify(name) %>Component {
  // Inputs
  
  // Outputs
  
  // State
}
<!-- src/component/files/__name@dasherize__/__name@dasherize__.component.html.template -->
<div class="<%= dasherize(name) %>">
  <p><%= dasherize(name) %> works!</p>
</div>
// src/component/files/__name@dasherize__/__name@dasherize__.component.scss.template
.<%= dasherize(name) %> {
  display: block;
}
// src/component/files/__name@dasherize__/__name@dasherize__.component.spec.ts.template
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { <%= classify(name) %>Component } from './<%= dasherize(name) %>.component';

describe('<%= classify(name) %>Component', () => {
  let component: <%= classify(name) %>Component;
  let fixture: ComponentFixture<<%= classify(name) %>Component>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      <% if (standalone) { %>imports: [<%= classify(name) %>Component]<% } else { %>declarations: [<%= classify(name) %>Component]<% } %>
    }).compileComponents();

    fixture = TestBed.createComponent(<%= classify(name) %>Component);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

AST Manipulation

// Modifying existing TypeScript files
import * as ts from 'typescript';
import { insertImport, addProviderToModule } from '@angular/schematics/utility/ast-utils';
import { InsertChange } from '@angular/schematics/utility/change';

function addImportToModule(options: any): Rule {
  return (tree: Tree) => {
    const modulePath = options.module;
    const text = tree.read(modulePath);
    
    if (!text) {
      throw new Error(`Could not read ${modulePath}`);
    }
    
    const sourceText = text.toString('utf-8');
    const source = ts.createSourceFile(
      modulePath,
      sourceText,
      ts.ScriptTarget.Latest,
      true
    );
    
    // Add import statement
    const importChange = insertImport(
      source,
      modulePath,
      'MyService',
      './my-service'
    ) as InsertChange;
    
    const recorder = tree.beginUpdate(modulePath);
    
    if (importChange.toAdd) {
      recorder.insertLeft(importChange.pos, importChange.toAdd);
    }
    
    tree.commitUpdate(recorder);
    
    return tree;
  };
}

// Adding to providers array
function addProvider(options: any): Rule {
  return (tree: Tree) => {
    const modulePath = findModulePath(tree, options);
    const source = readSourceFile(tree, modulePath);
    
    const providerChanges = addProviderToModule(
      source,
      modulePath,
      'MyService',
      './my-service'
    );
    
    const recorder = tree.beginUpdate(modulePath);
    
    providerChanges.forEach(change => {
      if (change instanceof InsertChange) {
        recorder.insertLeft(change.pos, change.toAdd);
      }
    });
    
    tree.commitUpdate(recorder);
    
    return tree;
  };
}

ng-add Schematic

// src/ng-add/index.ts
import { Rule, SchematicContext, Tree, chain } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';

export function ngAdd(options: any): Rule {
  return chain([
    addPackageToJson(),
    installPackages(),
    addToAngularJson(),
    updateAppConfig()
  ]);
}

function addPackageToJson(): Rule {
  return (tree: Tree) => {
    const packageJson = JSON.parse(tree.read('/package.json')!.toString());
    
    packageJson.dependencies = packageJson.dependencies || {};
    packageJson.dependencies['my-library'] = '^1.0.0';
    
    tree.overwrite('/package.json', JSON.stringify(packageJson, null, 2));
    
    return tree;
  };
}

function installPackages(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.addTask(new NodePackageInstallTask());
    return tree;
  };
}

function addToAngularJson(): Rule {
  return (tree: Tree) => {
    const angularJson = JSON.parse(tree.read('/angular.json')!.toString());
    
    // Add assets, styles, or scripts
    const projectName = Object.keys(angularJson.projects)[0];
    const project = angularJson.projects[projectName];
    
    const buildTarget = project.architect.build;
    buildTarget.options.styles = buildTarget.options.styles || [];
    
    if (!buildTarget.options.styles.includes('my-library/styles.css')) {
      buildTarget.options.styles.push('my-library/styles.css');
    }
    
    tree.overwrite('/angular.json', JSON.stringify(angularJson, null, 2));
    
    return tree;
  };
}

function updateAppConfig(): Rule {
  return (tree: Tree) => {
    const configPath = '/src/app/app.config.ts';
    
    if (!tree.exists(configPath)) {
      return tree;
    }
    
    let content = tree.read(configPath)!.toString();
    
    // Add import
    if (!content.includes("import { provideMyLibrary }")) {
      content = `import { provideMyLibrary } from 'my-library';\n${content}`;
    }
    
    // Add provider
    if (!content.includes('provideMyLibrary()')) {
      content = content.replace(
        /providers:\s*\[/,
        'providers: [\n    provideMyLibrary(),'
      );
    }
    
    tree.overwrite(configPath, content);
    
    return tree;
  };
}

Testing Schematics

// src/component/index.spec.ts
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('component schematic', () => {
  let runner: SchematicTestRunner;
  let tree: UnitTestTree;
  
  beforeEach(() => {
    runner = new SchematicTestRunner('schematics', collectionPath);
    tree = new UnitTestTree(Tree.empty());
    
    // Create minimal workspace
    tree.create('/package.json', JSON.stringify({ name: 'test' }));
    tree.create('/angular.json', JSON.stringify({
      projects: { app: { root: 'src', sourceRoot: 'src' } }
    }));
  });
  
  it('should create component files', async () => {
    const result = await runner.runSchematic('component', {
      name: 'test',
      path: 'src/app'
    }, tree);
    
    expect(result.files).toContain('/src/app/test/test.component.ts');
    expect(result.files).toContain('/src/app/test/test.component.html');
    expect(result.files).toContain('/src/app/test/test.component.scss');
    expect(result.files).toContain('/src/app/test/test.component.spec.ts');
  });
  
  it('should generate standalone component', async () => {
    const result = await runner.runSchematic('component', {
      name: 'button',
      path: 'src/app',
      standalone: true
    }, tree);
    
    const content = result.readContent('/src/app/button/button.component.ts');
    expect(content).toContain('standalone: true');
    expect(content).toContain('imports: [CommonModule]');
  });
  
  it('should not create spec file when withTests is false', async () => {
    const result = await runner.runSchematic('component', {
      name: 'simple',
      path: 'src/app',
      withTests: false
    }, tree);
    
    expect(result.files).not.toContain('/src/app/simple/simple.component.spec.ts');
  });
  
  it('should use correct naming conventions', async () => {
    const result = await runner.runSchematic('component', {
      name: 'user-profile',
      path: 'src/app'
    }, tree);
    
    const content = result.readContent('/src/app/user-profile/user-profile.component.ts');
    expect(content).toContain('export class UserProfileComponent');
    expect(content).toContain("selector: 'app-user-profile'");
  });
});

Custom Builders

// builders/build/index.ts
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
import { Observable, of } from 'rxjs';
import { switchMap, catchError } from 'rxjs/operators';

interface BuildOptions extends JsonObject {
  outputPath: string;
  optimize: boolean;
  watch: boolean;
}

export default createBuilder<BuildOptions>(
  (options: BuildOptions, context: BuilderContext): Observable<BuilderOutput> => {
    context.logger.info('Starting custom build...');
    
    return new Observable(subscriber => {
      try {
        // Run pre-build tasks
        context.logger.info(`Output path: ${options.outputPath}`);
        
        // Execute build logic
        const buildResult = performBuild(options);
        
        if (options.watch) {
          // Set up file watching
          const watcher = setupWatcher(options, () => {
            subscriber.next({ success: true });
          });
          
          return () => watcher.close();
        }
        
        subscriber.next({ success: buildResult });
        subscriber.complete();
      } catch (error) {
        subscriber.next({ success: false, error: String(error) });
        subscriber.complete();
      }
    });
  }
);

function performBuild(options: BuildOptions): boolean {
  // Build implementation
  return true;
}

function setupWatcher(options: BuildOptions, onChange: () => void) {
  // File watcher setup
  return { close: () => {} };
}
// builders.json
{
  "builders": {
    "build": {
      "implementation": "./builders/build",
      "schema": "./builders/build/schema.json",
      "description": "Custom build with additional features"
    }
  }
}

Publishing & Using Schematics

# Build schematics
npm run build

# Test locally
npm link
cd ../my-angular-project
npm link my-schematics
ng generate my-schematics:component button

# Publish to npm
npm publish

# Use in project
npm install my-schematics
ng generate my-schematics:component button

Package.json Configuration

{
  "name": "my-schematics",
  "version": "1.0.0",
  "schematics": "./src/collection.json",
  "builders": "./builders.json",
  "ng-add": {
    "save": "dependencies"
  },
  "peerDependencies": {
    "@angular/core": ">=17.0.0"
  }
}

Best Practices

Use Templates

Keep templates in files/ folder for maintainability

Validate Input

Use JSON schema with validation and prompts

Test Thoroughly

Write unit tests for all schematics

Provide ng-add

Make installation seamless with ng-add

Next: Error Handling

Master global error handling patterns