This is Part 4 of a 6-part series on Angular Schematics.
- ✅ Part 1 – Understanding Angular Schematics: Architecture & Core Concepts
- ✅ Part 2 – Creating Custom Generators with ng generate
- ✅ Part 3 – Building Installation Schematics with ng add
- Part 4 – Writing Migration Schematics with ng update ← You are here
- 🔜 Part 5 – Testing Schematics with Angular DevKit
- 🔜 Part 6 – Advanced Patterns, Publishing & Nx Integration
🤖 A note on this article: I used Claude to help reformat and structure the content to make it clearer and more presentable for publication.
References
- Angular CLI – ng update – Official CLI reference for ng update
- Angular DevKit Schematics – Full README – Complete reference for
@angular-devkit/schematics - Angular Update Guide – How Angular ships its own migrations
- TypeScript Compiler API – TypeScript AST documentation
In Part 3 we built an ng add schematic that configured a library on first install. In Part 2 we built ng generate schematics for on-demand code scaffolding and operational tasks.
Both of those are developer-triggered – a human decides when to run them. Part 4 is about the third and final mechanism: ng update, where the CLI automatically applies code transformations the moment a developer upgrades a package version.
This is the mechanism Angular itself uses to ship breaking changes. When you run ng update @angular/core and your code is automatically rewritten to use the new API – that is a migration schematic at work. The same infrastructure is available to every library author.
By the end of this article you will have built a complete migration schematic that:
- Detects a renamed component selector across all template files
- Rewrites a changed input property binding using the TypeScript Compiler API
- Updates an
app.config.tsprovider registration to match a new API - Runs automatically and in the correct order when consumers run
ng update @acme/ui
Table of Contents
- How ng update Works
- migrations.json – The Migration Manifest
- Version Gating – Running the Right Migration at the Right Time
- Project Structure
- Migration 1 – Renaming a Component Selector in Templates
- Migration 2 – Updating app.config.ts Provider Registration
- Composing Migrations with chain
- Safe Transformations – Principles and Guardrails
- Running and Verifying Locally
- Summary & What’s Next
How ng update Works
When a developer runs ng update @acme/ui, the CLI executes the following sequence:
ng update @acme/ui
│
▼
1. Resolve installed version of @acme/ui
→ reads node_modules/@acme/ui/package.json → e.g. "4.2.0"
│
▼
2. Resolve target version
→ latest on npm registry → e.g. "5.0.0"
│
▼
3. Read "ng-update" field in package.json
→ { "migrations": "./schematics/migrations.json" }
│
▼
4. Read migrations.json
→ find all migrations where version > 4.2.0 and version <= 5.0.0
│
▼
5. Execute matching migrations in version order
→ each migration is a Rule applied to the workspace Tree
│
▼
6. Commit all Tree changes to disk
7. npm install (updated package version)
Two things make ng update fundamentally different from ng generate:
Version gating. The CLI knows exactly which version the consumer is currently on and which version they are moving to. It runs only the migrations that fall within that delta. If a consumer is on 4.0.0 and updates to 5.0.0, they get every migration from 4.x onwards. If they are already on 4.9.0, they only get the migrations they haven’t yet applied.
Automatic execution. The consumer does not need to know that migrations exist. They run ng update, the CLI applies every applicable migration, and the consumer reviews the diff. Zero knowledge of the migration mechanism required on the consumer side.
migrations.json – The Migration Manifest
Just as collection.json is the manifest for ng generate and ng add schematics, migrations.json is the manifest for ng update migrations. Each entry declares a migration, the version it applies to, and the factory function to run.
{
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {
"migration-v5-rename-selector": {
"version": "5.0.0",
"description": "Renames acme-data-grid selector to acme-table across all templates",
"factory": "./migrations/v5/rename-selector#renameSelector"
},
"migration-v5-update-provider": {
"version": "5.0.0",
"description": "Updates provideAcmeUi() call signature in app.config.ts",
"factory": "./migrations/v5/update-provider#updateProvider"
}
}
}
The "version" field is the key that drives version gating. It declares the target version this migration prepares the consumer’s code for. The CLI runs this migration when the consumer is upgrading to this version from anything earlier.
Register this manifest in your library’s package.json:
{
"name": "@acme/ui",
"version": "5.0.0",
"schematics": "./schematics/collection.json",
"ng-update": {
"migrations": "./schematics/migrations.json",
"packageGroup": ["@acme/ui", "@acme/ui-icons", "@acme/ui-charts"]
}
}
The packageGroup field is optional but important for related packages – it tells the CLI that these packages should be updated together, ensuring migrations across the group run in a coordinated sequence.
Version Gating – Running the Right Migration at the Right Time
Version gating is what separates ng update migrations from a simple ng generate one-off. Consider a library that has shipped migrations across multiple major versions:
{
"schematics": {
"migration-v3-rename-prefix": {
"version": "3.0.0",
"factory": "./migrations/v3/rename-prefix#renamePrefix"
},
"migration-v4-update-imports": {
"version": "4.0.0",
"factory": "./migrations/v4/update-imports#updateImports"
},
"migration-v5-rename-selector": {
"version": "5.0.0",
"factory": "./migrations/v5/rename-selector#renameSelector"
}
}
}
A consumer on v2.x upgrading directly to v5.0.0 will have all three migrations applied in sequence – v3, then v4, then v5. A consumer already on v4.x gets only the v5 migration. The CLI handles this automatically. You write each migration against its specific version boundary and the CLI ensures the right ones run in the right order.
This is the correct way to handle long upgrade paths. Never consolidate migrations across versions – a consumer who skipped v3 and v4 needs those migrations too, and the CLI will apply them in order as long as each one declares its version correctly.
Project Structure
@acme/ui/
├── package.json ← "ng-update" points to migrations.json
└── schematics/
├── collection.json ← ng generate / ng add schematics
├── migrations.json ← ng update migrations manifest
└── migrations/
└── v5/
├── index.ts ← composes all v5 migrations
├── rename-selector/
│ ├── index.ts
│ └── schema.json
└── update-provider/
├── index.ts
└── schema.json
Keep each migration in its own folder under a version directory. This makes it trivial to audit which migrations exist for which version, and keeps each factory function focused on a single transformation.
Migration 1 – Renaming a Component Selector in Templates
The first migration renames the acme-data-grid selector to acme-table across every HTML template file in the workspace. This is a string replacement across multiple files – straightforward, but it must handle both the opening and closing tags and be idempotent.
// schematics/migrations/v5/rename-selector/index.ts
import { Rule, Tree } from '@angular-devkit/schematics';
export function renameSelector(): Rule {
return (tree: Tree) => {
// Walk every file in the workspace
tree.visit((filePath) => {
// Only process HTML template files
if (!filePath.endsWith('.html')) return;
const content = tree.read(filePath);
if (!content) return;
const original = content.toString('utf-8');
// Replace both opening and closing tags
const updated = original
.replace(/<acme-data-grid/g, '<acme-table')
.replace(/<\/acme-data-grid>/g, '</acme-table>');
// Only write if something actually changed - avoids unnecessary diffs
if (updated !== original) {
tree.overwrite(filePath, updated);
}
});
return tree;
};
}
Three things worth noting in this implementation:
tree.visit() walks every file in the entire workspace Tree – including files in node_modules and dist if they are present. For a real migration, you should scope the walk to src/ to avoid processing files that don’t belong to the consumer’s application:
tree.visit((filePath) => {
if (!filePath.startsWith('/src/')) return;
if (!filePath.endsWith('.html')) return;
// ...
});
Write only on change. The if (updated !== original) guard means only files that actually contain acme-data-grid are written. This keeps the diff clean – only files with genuine changes appear in the UPDATE output.
Global regex flags. The g flag is essential – without it, only the first occurrence per file is replaced.
Migration 2 – Updating app.config.ts Provider Registration
Standalone Angular applications configure providers in app.config.ts. When a library changes its provider API – for example, provideAcmeUi() gains a required options argument – the migration needs to find and update that call.
@schematics/angular/utility exports addRootProvider, a purpose-built utility for safely adding or updating provider registrations in standalone application configurations. It is the officially supported way to interact with app.config.ts in schematics.
// schematics/migrations/v5/update-provider/index.ts
import { Rule } from '@angular-devkit/schematics';
import { addRootProvider } from '@schematics/angular/utility';
export function updateProvider(options: { project: string }): Rule {
// addRootProvider locates app.config.ts for the given project,
// finds the providers array, and inserts the expression safely.
// It handles imports, formatting, and idempotency automatically.
return addRootProvider(options.project, ({ code, external }) =>
code`${external('provideAcmeUi', '@acme/ui')}({ animations: true })`
);
}
addRootProvider accepts a callback that returns a code tagged template literal. Within it:
external('provideAcmeUi', '@acme/ui')– references theprovideAcmeUisymbol from@acme/ui, and ensures the correctimportstatement is present in the file.
The utility handles locating app.config.ts, navigating the ApplicationConfig providers array, inserting the new entry, and adding the import statement – without you writing a single line of file-manipulation code.
The before and after:
Before:
// src/app/app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
]
};
After:
// src/app/app.config.ts
import { provideAcmeUi } from '@acme/ui';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAcmeUi({ animations: true }),
]
};
💡 Note on complex TypeScript transformations:
addRootProvidercovers the most common provider registration pattern cleanly. For more complex TypeScript transformations – renaming properties across arbitrary.tsfiles, restructuring import paths, or handling multiple overloaded call signatures – the TypeScript Compiler API gives you a fully typed AST to work against precisely. For the sake of simplicity in this article, the regex-based template replacement in Migration 1 and the utility-based approach here cover the most practical day-to-day migration patterns. The TypeScript Compiler API is an advanced topic covered in Part 6 of this series.
Composing Migrations with chain
Individual migrations can be composed using chain – exactly as with ng generate Rules. This is useful when you want to expose a single ng generate entry point that applies all v5 migrations at once for developers who prefer to run them manually before committing to ng update:
// schematics/migrations/v5/index.ts
import { Rule, chain } from '@angular-devkit/schematics';
import { renameSelector } from './rename-selector';
import { updateProvider } from './update-provider';
export function migrateV5(options: { project: string }): Rule {
return chain([
renameSelector(),
updateProvider(options),
]);
}
Register this in collection.json alongside the individual migration entries:
{
"schematics": {
"migrate-v5": {
"description": "Applies all @acme/ui v5 migrations manually",
"factory": "./migrations/v5/index#migrateV5"
}
}
}
Developers can now choose their path:
# Automatic - runs on ng update, version-gated
ng update @acme/ui
# Manual - opt-in, previewable with --dry-run
ng generate @acme/ui:migrate-v5 --dry-run
ng generate @acme/ui:migrate-v5
This is the same-factory-two-delivery-mechanisms pattern we discussed in Part 2. The underlying Rules are identical – the difference is entirely in how and when they are triggered.
Safe Transformations – Principles and Guardrails
A migration schematic that produces incorrect output is worse than no migration at all – it silently corrupts a consumer’s codebase. These principles keep migrations safe:
Guard with a quick string check first. Before running any replacement, check whether the target string even exists in the file. This skips files that couldn’t possibly be affected and keeps large workspace migrations fast:
if (!content.includes('provideAcmeUi')) return tree;
Write only on actual change. Compare the updated string to the original before calling
tree.overwrite(). This keeps the diff clean – only files with genuine changes appear in the
UPDATE output:
if (updated !== original) {
tree.overwrite(filePath, updated);
}
Use the most specific pattern you can. For template migrations, use a regex that matches the exact tag or attribute being renamed rather than a broad identifier. The more specific the match, the lower the chance of touching code that shouldn’t be touched.
Prefer official utility functions over manual file manipulation. addRootProvider, addDependency, and updateWorkspace from @schematics/angular/utility handle idempotency, file integrity, and edge cases that are easy to get wrong when writing raw string replacements against structured files like angular.json or app.config.ts.
Scope tree.visit() to src/. Walking the entire workspace Tree without a path filter will include node_modules, dist, and .angular cache directories. Always filter early:
tree.visit((filePath) => {
if (!filePath.startsWith('/src/')) return;
// ...
});
Always test with --dry-run before committing. During development, run your migration against a representative workspace with --dry-run and review the diff carefully before the first real commit.
Ship test coverage. Every migration should have unit tests covering: a file that contains the target, a file that doesn’t, a file where the change was already applied (idempotency), and a file with multiple occurrences. Part 5 covers SchematicTestRunner and UnitTestTree for exactly this.
Running and Verifying Locally
Testing ng update migrations locally requires the --from and --to flags to simulate a version upgrade without actually changing the installed package:
# Simulate upgrading from v4.0.0 to v5.0.0
ng update @acme/ui --from=4.0.0 --to=5.0.0 --migrate-only
# Preview without writing - always do this first
ng update @acme/ui --from=4.0.0 --to=5.0.0 --migrate-only --dry-run
# Run a specific migration by name
ng update @acme/ui --migrate-only --name=migration-v5-rename-selector
The --migrate-only flag tells the CLI to run the migrations without actually updating the package version in package.json – essential during development. The --name flag targets a single migration, which is invaluable when iterating on a specific transformation.
The expected terminal output after a successful migration run:
Using package manager: npm
Collecting installed dependencies...
Found 1 migration to apply.
@acme/ui > migration-v5-rename-selector
Renames acme-data-grid selector to acme-table across all templates
UPDATE src/app/features/dashboard/dashboard.component.html (842 bytes)
UPDATE src/app/features/products/products-list.component.html (1203 bytes)
Migration successful.
Summary & What’s Next
We built two migration schematics and wired them into a migrations.json manifest that the Angular CLI reads automatically on ng update.
The key concepts:
migrations.json is the manifest that registers migrations by target version. The "ng-update" field in package.json points the CLI to it. packageGroup coordinates updates across related packages.
Version gating is automatic – the CLI computes the version delta and runs only the migrations that fall within it, in version order. Write each migration against its specific version boundary and never consolidate across versions.
Template migrations use tree.visit() with a scoped path filter and global regex replacement. Scope to /src/, use the g flag, and write only when the content actually changes.
TypeScript file migrations can often be handled with targeted string replacement when the pattern is specific enough – a known function name, a predictable file path, a no-argument call signature. Use a quick string guard before replacing and an idempotency guard to prevent double-applying.
The same factory can serve both ng update (automatic, version-gated) and ng generate (manual, previewable) – register it in both migrations.json and collection.json.
🔔 For complex TypeScript transformations – renaming properties across arbitrary files, restructuring import paths, handling overloaded signatures – the TypeScript Compiler API is the right tool. We cover that in Part 6 of this series.
In Part 5 we cover testing – how to use SchematicTestRunner and UnitTestTree to write fast, reliable tests for every schematic type we have built across this series. Every migration in this article needs a test, and Part 5 shows exactly how to write them.
Series Roadmap
| Part | Topic | Status |
|---|---|---|
| Part 1 | Understanding Angular Schematics – Architecture & Core Concepts | ✅ Published |
| Part 2 | Creating Custom Generators with ng generate | ✅ Published |
| Part 3 | Building Installation Schematics with ng add | ✅ Published |
| Part 4 | Writing Migration Schematics with ng update | ✅ You are here |
| Part 5 | Testing Schematics with Angular DevKit | 🔜 Coming Soon |
| Part 6 | Advanced Patterns, Publishing & Nx Integration | 🔜 Coming Soon |
Built with Angular v21 · @angular-devkit/schematics · @angular-devkit/core