> ## 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.

# 29. Custom Schematics

> Create custom Angular CLI schematics and builders

<Frame>
  <img src="https://mintcdn.com/devweeekends/AEOaWh79Ur7CdHHv/images/courses/angular-crash-course/angular-architecture.svg?fit=max&auto=format&n=AEOaWh79Ur7CdHHv&q=85&s=d182e6a63e3b3c9b80c20d8b866c7812" alt="Custom Schematics" width="1000" height="600" data-path="images/courses/angular-crash-course/angular-architecture.svg" />
</Frame>

## Custom Schematics Overview

<Info>
  **Estimated Time**: 3 hours | **Difficulty**: Advanced | **Prerequisites**: Angular CLI, TypeScript
</Info>

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

```bash theme={null}
# 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

```json theme={null}
// 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).

<Tip>
  **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.
</Tip>

```json theme={null}
// 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"]
}
```

```typescript theme={null}
// 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

```typescript theme={null}
// 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

```typescript theme={null}
// 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
}
```

```html theme={null}
<!-- src/component/files/__name@dasherize__/__name@dasherize__.component.html.template -->
<div class="<%= dasherize(name) %>">
  <p><%= dasherize(name) %> works!</p>
</div>
```

```scss theme={null}
// src/component/files/__name@dasherize__/__name@dasherize__.component.scss.template
.<%= dasherize(name) %> {
  display: block;
}
```

```typescript theme={null}
// 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

```typescript theme={null}
// 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

```typescript theme={null}
// 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

```typescript theme={null}
// 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

```typescript theme={null}
// 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: () => {} };
}
```

```json theme={null}
// builders.json
{
  "builders": {
    "build": {
      "implementation": "./builders/build",
      "schema": "./builders/build/schema.json",
      "description": "Custom build with additional features"
    }
  }
}
```

***

## Publishing & Using Schematics

```bash theme={null}
# 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

```json theme={null}
{
  "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

<CardGroup cols={2}>
  <Card title="Use Templates" icon="file-code">
    Keep templates in files/ folder for maintainability
  </Card>

  <Card title="Validate Input" icon="shield">
    Use JSON schema with validation and prompts
  </Card>

  <Card title="Test Thoroughly" icon="vial">
    Write unit tests for all schematics
  </Card>

  <Card title="Provide ng-add" icon="plus">
    Make installation seamless with ng-add
  </Card>
</CardGroup>

***

<Card title="Next: Error Handling" icon="arrow-right" href="/courses/angular-crash-course/30-error-handling">
  Master global error handling patterns
</Card>
