Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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. Think of them as “recipes” that the Angular CLI follows to scaffold files with your team’s exact conventions baked in. Instead of a new developer spending 20 minutes creating a component file, a spec file, a story file, and wiring up the barrel export — all while trying to match the team’s patterns — they run one command and get perfect boilerplate every time. The real value is consistency, not speed. Yes, schematics save time. But the bigger win is that every component, service, and feature module across your entire codebase follows the exact same structure. Code reviews get faster because reviewers are not nitpicking file organization — the schematic already enforced it.
┌─────────────────────────────────────────────────────────────────────────┐
│                    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

The schema is the “contract” between the schematic and the user. It defines what options are available, their types, default values, and validation rules. The x-prompt property creates interactive prompts in the terminal, making your schematic feel like a first-class CLI tool. Good schemas make common cases easy (sensible defaults) and uncommon cases possible (optional flags).
Practical tip: Always provide a $default with "$source": "argv" for the primary argument (usually name). This lets users write ng g my-schematics:component button instead of ng g my-schematics:component --name=button — a small UX improvement that matters when you run the command dozens of times a day.
// 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';

// The factory function returns a Rule -- a function that transforms a Tree.
// The Tree is a virtual file system that represents pending changes.
// No actual files are written until the schematic completes successfully.
// This means if any step fails, the entire operation is rolled back cleanly.
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