Custom Schematics Overview
Estimated Time: 3 hours | Difficulty: Advanced | Prerequisites: Angular CLI, TypeScript
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
Copy
# 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
Copy
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
Copy
// 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
Copy
// 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"]
}
Copy
// 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
Copy
// 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
Copy
// 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
}
Copy
<!-- src/component/files/__name@dasherize__/__name@dasherize__.component.html.template -->
<div class="<%= dasherize(name) %>">
<p><%= dasherize(name) %> works!</p>
</div>
Copy
// src/component/files/__name@dasherize__/__name@dasherize__.component.scss.template
.<%= dasherize(name) %> {
display: block;
}
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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: () => {} };
}
Copy
// builders.json
{
"builders": {
"build": {
"implementation": "./builders/build",
"schema": "./builders/build/schema.json",
"description": "Custom build with additional features"
}
}
}
Publishing & Using Schematics
Copy
# 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
Copy
{
"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